Arag / app /superadmin /templates /superadmin.html
AuthorBot
Allow SuperAdmin to proxy author admin growth routes.
9e4dfdc
Raw
History Blame Contribute Delete
98.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AuthorBot — SuperAdmin Control Center</title>
<meta name="description" content="Platform-wide SuperAdmin control center for Author RAG SaaS.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script src="/static/auth-client.js"></script>
<script src="/static/superadmin-shell.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f8f9fc; --white: #ffffff; --surface: #ffffff; --surface2: #f1f3f9;
--border: #e5e7ef; --border-light: #eef0f6;
--accent: #dc2626; --accent-hover: #b91c1c; --accent-soft: rgba(220,38,38,0.06);
--accent2: #ef4444; --indigo: #6366f1; --indigo-soft: rgba(99,102,241,0.08);
--text: #1e293b; --text2: #334155; --muted: #64748b; --light: #94a3b8;
--green: #10b981; --green-bg: rgba(16,185,129,0.1);
--red: #ef4444; --red-bg: rgba(239,68,68,0.08);
--yellow: #f59e0b; --yellow-bg: rgba(245,158,11,0.1);
--blue: #3b82f6; --blue-bg: rgba(59,130,246,0.08);
--radius: 14px; --radius-sm: 10px; --radius-xs: 6px;
--shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03);
--shadow-lg: 0 4px 24px rgba(0,0,0,0.06), 0 12px 48px rgba(0,0,0,0.04);
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--sidebar-w: 260px;
}
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; -webkit-font-smoothing: antialiased; }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideIn { from { opacity: 0; transform: translateX(-16px); } to { opacity: 1; transform: translateX(0); } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
.skeleton { background: linear-gradient(90deg, var(--surface2) 25%, #e8eaf2 50%, var(--surface2) 75%); background-size: 800px 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm); }
/* Auth */
.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #fef2f2 0%, #fff1f2 50%, #f8f9fc 100%); position: relative; overflow: hidden; }
.auth-wrap::before { content: ''; position: absolute; width: 500px; height: 500px; border-radius: 50%; background: radial-gradient(circle, rgba(220,38,38,0.06) 0%, transparent 70%); top: -200px; right: -100px; }
.auth-box { background: var(--white); border: 1px solid var(--border-light); border-radius: 24px; padding: 48px 40px; width: 100%; max-width: 440px; box-shadow: var(--shadow-lg); position: relative; z-index: 1; animation: scaleIn 0.5s ease; }
.auth-logo { font-size: 1.8rem; font-weight: 800; color: var(--accent); margin-bottom: 6px; letter-spacing: -0.02em; }
.auth-sub { color: var(--muted); font-size: 14px; margin-bottom: 36px; line-height: 1.5; }
.security-badge { display: inline-flex; align-items: center; gap: 6px; background: var(--red-bg); border: 1px solid rgba(220,38,38,0.15); border-radius: 8px; padding: 8px 14px; font-size: 12px; font-weight: 600; color: var(--accent); margin-bottom: 24px; }
/* Form */
.form-group { margin-bottom: 20px; }
.form-label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; letter-spacing: 0.05em; text-transform: uppercase; }
.form-input { width: 100%; padding: 12px 16px; border-radius: var(--radius-sm); border: 1.5px solid var(--border); background: var(--bg); color: var(--text); font-size: 14px; font-family: inherit; transition: border-color var(--transition), box-shadow var(--transition); }
.form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.form-input::placeholder { color: var(--light); }
textarea.form-input { resize: vertical; min-height: 80px; }
select.form-input { cursor: pointer; }
/* Buttons */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 24px; border-radius: var(--radius-sm); border: none; font-weight: 600; font-size: 14px; font-family: inherit; cursor: pointer; transition: all var(--transition); }
.btn-primary { background: var(--accent); color: white; box-shadow: 0 2px 8px rgba(220,38,38,0.25); }
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(220,38,38,0.35); }
.btn-secondary { background: var(--surface2); color: var(--text2); border: 1px solid var(--border); }
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger { background: var(--red-bg); color: var(--red); }
.btn-danger:hover { background: var(--red); color: white; }
.btn-success { background: var(--green-bg); color: var(--green); }
.btn-success:hover { background: var(--green); color: white; }
.btn-sm { padding: 7px 14px; font-size: 12px; border-radius: var(--radius-xs); }
.btn-block { width: 100%; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Sidebar */
.sidebar { position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar-w); background: var(--white); border-right: 1px solid var(--border-light); display: flex; flex-direction: column; padding: 24px 16px; z-index: 100; overflow-y: auto; overflow-x: hidden; }
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.sidebar-logo { font-size: 1.25rem; font-weight: 800; color: var(--accent); padding: 0 12px; margin-bottom: 4px; }
.sidebar-tier { font-size: 10px; font-weight: 700; color: var(--accent); letter-spacing: 0.1em; text-transform: uppercase; padding: 0 12px; margin-bottom: 28px; background: var(--red-bg); display: inline-flex; padding: 4px 12px; border-radius: 4px; }
.nav-section { font-size: 10px; font-weight: 700; color: var(--light); letter-spacing: 0.1em; text-transform: uppercase; padding: 16px 12px 8px; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius-sm); color: var(--muted); text-decoration: none; font-size: 13.5px; font-weight: 500; cursor: pointer; border: none; background: none; width: 100%; transition: all var(--transition); margin-bottom: 2px; text-align: left; }
.nav-item .nav-icon { width: 20px; text-align: center; font-size: 15px; }
.nav-item:hover { background: var(--accent-soft); color: var(--accent); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.sidebar-footer { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--border-light); }
/* Main */
.main { margin-left: var(--sidebar-w); padding: 32px 40px; min-height: 100vh; flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-x: hidden; }
.page { display: none; } .page.active { display: block; animation: fadeIn 0.3s ease; }
.page-header { margin-bottom: 28px; }
.page-title { font-size: 1.6rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; margin-bottom: 4px; display: flex; align-items: center; gap: 10px; }
.page-sub { color: var(--muted); font-size: 14px; }
/* Info tooltips */
.info-tip { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 700; cursor: help; border: 1px solid rgba(220,38,38,0.2); transition: all var(--transition); flex-shrink: 0; }
.info-tip:hover { background: var(--accent); color: white; transform: scale(1.1); }
.info-tip .tip-text { visibility: hidden; opacity: 0; position: absolute; z-index: 1000; top: calc(100% + 10px); left: 0; transform: translateY(-4px); width: 280px; padding: 12px 16px; background: var(--text); color: #f1f5f9; border-radius: var(--radius-sm); font-size: 12px; font-weight: 400; line-height: 1.6; box-shadow: var(--shadow-lg); transition: all 0.2s ease; pointer-events: none; }
.info-tip .tip-text::after { content: ''; position: absolute; bottom: 100%; left: 16px; border-width: 5px; border-style: solid; border-color: transparent transparent var(--text) transparent; }
.info-tip:hover .tip-text { visibility: visible; opacity: 1; transform: translateY(0); }
/* Cards */
.card { background: var(--white); border: 1px solid var(--border-light); border-radius: var(--radius); padding: 24px; margin-bottom: 20px; box-shadow: var(--shadow); transition: box-shadow var(--transition); }
.card:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.06); }
.card-title { font-size: 15px; font-weight: 700; margin-bottom: 4px; }
.card-sub { font-size: 13px; color: var(--muted); margin-bottom: 16px; }
/* Stats */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 28px; }
.stat-card { background: var(--white); border: 1px solid var(--border-light); border-radius: var(--radius); padding: 24px; box-shadow: var(--shadow); transition: all var(--transition); cursor: default; position: relative; overflow: hidden; }
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(220,38,38,0.08); }
.stat-icon { font-size: 20px; margin-bottom: 12px; }
.stat-val { font-size: 2rem; font-weight: 800; letter-spacing: -0.02em; }
.stat-val.red { color: var(--accent); } .stat-val.green { color: var(--green); } .stat-val.blue { color: var(--blue); } .stat-val.yellow { color: var(--yellow); }
.stat-label { font-size: 12px; color: var(--muted); margin-top: 4px; font-weight: 500; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
thead { background: var(--surface2); }
th { text-align: left; padding: 12px 16px; color: var(--muted); font-weight: 600; font-size: 11px; letter-spacing: 0.05em; text-transform: uppercase; white-space: nowrap; }
td { padding: 14px 16px; border-bottom: 1px solid var(--border-light); transition: background var(--transition); }
tr:hover td { background: var(--accent-soft); }
tr:last-child td { border-bottom: none; }
/* Badges */
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
.badge-active { background: var(--green-bg); color: var(--green); }
.badge-suspended { background: var(--red-bg); color: var(--red); }
.badge-info { background: var(--blue-bg); color: var(--blue); }
.badge-warning { background: var(--yellow-bg); color: var(--yellow); }
.badge-green { background: var(--green-bg); color: var(--green); }
.badge-blue { background: var(--blue-bg); color: var(--blue); }
.badge-gray { background: var(--surface2); color: var(--muted); border: 1px solid var(--border); }
/* Toast */
.toast-container { position: fixed; top: 24px; right: 24px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
.toast { background: var(--white); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 14px 20px; box-shadow: var(--shadow-lg); display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 500; animation: slideIn 0.3s ease; min-width: 280px; }
.toast.success { border-left: 3px solid var(--green); }
.toast.error { border-left: 3px solid var(--red); }
.toast.info { border-left: 3px solid var(--blue); }
.toast-close { margin-left: auto; cursor: pointer; color: var(--light); font-size: 16px; background: none; border: none; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(4px); z-index: 1000; display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
.modal-overlay.active { opacity: 1; pointer-events: auto; }
.modal { background: var(--white); border-radius: 20px; padding: 32px; width: 100%; max-width: 520px; box-shadow: var(--shadow-lg); transform: scale(0.95); transition: transform 0.2s; max-height: 90vh; overflow-y: auto; }
.modal-overlay.active .modal { transform: scale(1); }
.modal-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 8px; }
.modal-sub { font-size: 13px; color: var(--muted); margin-bottom: 24px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
/* Health status */
.health-card { display: flex; align-items: center; gap: 16px; padding: 20px; border-radius: var(--radius); border: 1px solid var(--border-light); margin-bottom: 12px; background: var(--white); }
.health-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.health-dot.ok { background: var(--green); box-shadow: 0 0 8px rgba(16,185,129,0.4); }
.health-dot.warn { background: var(--yellow); box-shadow: 0 0 8px rgba(245,158,11,0.4); }
.health-dot.err { background: var(--red); box-shadow: 0 0 8px rgba(239,68,68,0.4); animation: pulse 1.5s infinite; }
/* Audit log */
.audit-entry { padding: 14px 16px; border-bottom: 1px solid var(--border-light); font-size: 13px; display: flex; gap: 12px; align-items: flex-start; transition: background var(--transition); }
.audit-entry:hover { background: var(--surface2); }
.audit-time { font-size: 11px; color: var(--light); white-space: nowrap; font-family: 'Inter', monospace; min-width: 130px; }
.audit-action { font-weight: 600; color: var(--accent); }
.audit-detail { color: var(--muted); font-size: 12px; margin-top: 4px; }
/* Misc */
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.toolbar-spacer { flex: 1; }
.search-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-sm); max-width: 320px; transition: all var(--transition); }
.search-bar:focus-within { border-color: var(--accent); background: var(--white); box-shadow: 0 0 0 3px var(--accent-soft); }
.search-bar input { border: none; background: none; outline: none; font-size: 13px; color: var(--text); width: 100%; font-family: inherit; }
.grid-2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
.chart-container { position: relative; height: 280px; }
.empty-state { text-align: center; padding: 60px 20px; }
.empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
.empty-state .empty-title { font-size: 16px; font-weight: 600; margin-bottom: 6px; }
.empty-state .empty-sub { font-size: 13px; color: var(--muted); }
.flex { display: flex; } .items-center { align-items: center; } .justify-between { justify-content: space-between; }
.gap-3 { gap: 12px; } .gap-4 { gap: 16px; } .mt-4 { margin-top: 16px; } .mb-4 { margin-bottom: 16px; }
.text-sm { font-size: 13px; } .text-xs { font-size: 11px; } .text-muted { color: var(--muted); }
.fw-600 { font-weight: 600; } .fw-700 { font-weight: 700; }
.hidden { display: none !important; }
@media (max-width: 768px) { .sidebar { display: none; } .main { margin-left: 0; padding: 20px; } .grid-2 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="toast-container" id="toast-container"></div>
<div class="modal-overlay" id="modal-overlay"><div class="modal" id="modal-content"></div></div>
<!-- BOOT LOADER (shown until session restore completes) -->
<div class="auth-wrap" id="boot-loader" style="display:flex;">
<div class="auth-box" style="text-align:center;max-width:360px;">
<div class="auth-logo">AuthorBot</div>
<p class="auth-sub" style="margin-bottom:0;">Restoring your session…</p>
</div>
</div>
<!-- AUTH -->
<div class="auth-wrap" id="auth-screen" style="display:none;">
<div class="auth-box">
<div class="auth-logo">🔒 AuthorBot</div>
<p class="auth-sub">SuperAdmin Control Center — restricted access. All actions are audit-logged.</p>
<div class="security-badge">🛡️ SuperAdmin Access Only</div>
<div class="form-group"><label class="form-label">Email / Username</label><input type="text" class="form-input" id="sa-user" placeholder="admin@example.com" autocomplete="username"></div>
<div class="form-group"><label class="form-label">Password</label><input type="password" class="form-input" id="sa-pass" placeholder="••••••••" autocomplete="current-password"></div>
<button class="btn btn-primary btn-block" id="sa-login-btn">Authenticate</button>
<p id="sa-login-msg" style="margin-top:12px;font-size:13px;min-height:18px;"></p>
</div>
</div>
<!-- TOTP ENROLLMENT (mandatory before dashboard) -->
<div class="auth-wrap" id="totp-setup-screen" style="display:none;">
<div class="auth-box" style="max-width:480px;">
<div class="auth-logo">🔐 Two-Factor Setup</div>
<p class="auth-sub">SuperAdmin requires an authenticator app (Google Authenticator, Authy, etc.). Scan the QR code, then enter the 6-digit code to continue.</p>
<div id="totp-qr-wrap" style="text-align:center;margin:20px 0;min-height:200px;">
<div class="text-muted text-sm">Generating QR code…</div>
</div>
<div class="form-group" style="display:none;" id="totp-manual-wrap">
<label class="form-label">Manual entry key</label>
<input type="text" class="form-input" id="totp-manual-key" readonly>
</div>
<div class="form-group">
<label class="form-label">Authenticator code</label>
<input type="text" class="form-input" id="totp-enroll-code" placeholder="6-digit code" maxlength="6" autocomplete="one-time-code" inputmode="numeric">
</div>
<button class="btn btn-primary btn-block" id="totp-confirm-btn">Activate Two-Factor</button>
<p id="totp-setup-msg" style="margin-top:12px;font-size:13px;min-height:18px;"></p>
</div>
</div>
<!-- TOTP UNLOCK (once per browser session) -->
<div class="auth-wrap" id="totp-unlock-screen" style="display:none;">
<div class="auth-box" style="max-width:420px;">
<div class="auth-logo">🔐 Verify Identity</div>
<p class="auth-sub">Enter the 6-digit code from your authenticator app. You only need to do this once per session — not on every settings change.</p>
<div class="form-group">
<label class="form-label">Authenticator code</label>
<input type="text" class="form-input" id="totp-unlock-code" placeholder="6-digit code" maxlength="6" autocomplete="one-time-code" inputmode="numeric">
</div>
<button class="btn btn-primary btn-block" id="totp-unlock-btn">Continue to Dashboard</button>
<p id="totp-unlock-msg" style="margin-top:12px;font-size:13px;min-height:18px;"></p>
</div>
</div>
<!-- DASHBOARD -->
<div id="dashboard" style="display:none;">
<nav class="sidebar">
<div class="sidebar-logo">AuthorBot</div>
<div class="sidebar-tier">SuperAdmin</div>
<div class="nav-section">Platform</div>
<button class="nav-item active" data-page="overview"><span class="nav-icon">📊</span> Overview</button>
<button class="nav-item" data-page="authors"><span class="nav-icon">👥</span> Authors</button>
<button class="nav-item" data-page="subscriptions"><span class="nav-icon">💳</span> Subscriptions</button>
<button class="nav-item" data-page="token-management"><span class="nav-icon">🪙</span> Token Management</button>
<button class="nav-item" data-page="platform-analytics"><span class="nav-icon">📈</span> Analytics</button>
<div class="nav-section">Management</div>
<button class="nav-item" data-page="grant"><span class="nav-icon">🔑</span> Grant Access</button>
<button class="nav-item" data-page="plans"><span class="nav-icon">📋</span> Plan Templates</button>
<button class="nav-item" data-page="announcements"><span class="nav-icon">📣</span> Announcements</button>
<div class="nav-section">Author Config</div>
<button class="nav-item" data-page="author-select"><span class="nav-icon">🎯</span> Select Author</button>
<button class="nav-item" data-page="sa-abtests"><span class="nav-icon">🧪</span> A/B Tests</button>
<button class="nav-item" data-page="sa-webhooks"><span class="nav-icon">🔔</span> Webhooks</button>
<button class="nav-item" data-page="sa-referral"><span class="nav-icon">🎁</span> Referral</button>
<button class="nav-item" data-page="sa-whitelabel"><span class="nav-icon">🏷️</span> White-Label</button>
<button class="nav-item" data-page="sa-apikeys"><span class="nav-icon">🔑</span> API Keys</button>
<button class="nav-item" data-page="sa-email"><span class="nav-icon">📧</span> Email Integration</button>
<button class="nav-item" data-page="sa-agency"><span class="nav-icon">🏢</span> Agency</button>
<button class="nav-item" data-page="sa-sso"><span class="nav-icon">🛡️</span> SSO</button>
<button class="nav-item" data-page="sa-gdpr"><span class="nav-icon">⚖️</span> GDPR</button>
<div class="nav-section">System</div>
<button class="nav-item" data-page="health"><span class="nav-icon">❤️</span> Health Monitor</button>
<button class="nav-item" data-page="audit"><span class="nav-icon">📜</span> Audit Log</button>
<button class="nav-item" data-page="settings"><span class="nav-icon">⚙️</span> Settings</button>
<div class="sidebar-footer">
<button class="nav-item" id="sa-logout-btn" style="color:var(--red);"><span class="nav-icon">🚪</span> Sign Out</button>
</div>
</nav>
<main class="main">
<!-- OVERVIEW -->
<div class="page active" id="page-overview">
<div class="page-header"><h1 class="page-title">Platform Overview <span class="info-tip"><span class="tip-text">Your real-time command center. See total registered authors, active subscriptions, and platform health at a glance. Data refreshes each time you visit.</span></span></h1><p class="page-sub">Real-time platform metrics and health status</p></div>
<div class="stat-grid">
<div class="stat-card"><div class="stat-icon">👥</div><div class="stat-val red" id="sa-total-authors"></div><div class="stat-label">Total Authors</div></div>
<div class="stat-card"><div class="stat-icon"></div><div class="stat-val green" id="sa-active-subs"></div><div class="stat-label">Active Subscriptions</div></div>
<div class="stat-card"><div class="stat-icon">📚</div><div class="stat-val blue" id="sa-total-books"></div><div class="stat-label">Total Books</div></div>
<div class="stat-card"><div class="stat-icon">💬</div><div class="stat-val yellow" id="sa-total-sessions"></div><div class="stat-label">Today's Sessions</div></div>
</div>
<div class="grid-2">
<div class="card"><div class="card-title">Platform Sessions — Last 30 Days</div><div class="chart-container"><canvas id="chart-platform"></canvas></div></div>
<div class="card"><div class="card-title">Quick Actions</div><div class="card-sub">Common management tasks</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<button class="btn btn-secondary" onclick="nav('grant')">🔑 Grant Author Access</button>
<button class="btn btn-secondary" onclick="nav('authors')">👥 Manage Authors</button>
<button class="btn btn-secondary" onclick="nav('health')">❤️ Check System Health</button>
<button class="btn btn-secondary" onclick="nav('audit')">📜 View Audit Log</button>
</div>
</div>
</div>
</div>
<!-- AUTHORS -->
<div class="page" id="page-authors">
<div class="page-header"><h1 class="page-title">Author Management <span class="info-tip"><span class="tip-text">All registered authors on the platform. Suspend, unsuspend, or permanently remove accounts. Deleting an author removes all books, chats, and subscription data.</span></span></h1><p class="page-sub">Manage all registered author accounts</p></div>
<div class="toolbar">
<div class="search-bar"><span>🔍</span><input type="text" placeholder="Search authors..." id="author-search" oninput="filterAuthors()"></div>
<div class="toolbar-spacer"></div>
<span class="text-xs text-muted" id="author-count">— authors</span>
<button class="btn btn-primary btn-sm" onclick="showCreateAuthorModal()">+ Create Author</button>
<button class="btn btn-secondary btn-sm" onclick="nav('grant')">🔑 Grant Access</button>
</div>
<div class="card" style="padding:0;"><div class="table-wrap"><table><thead><tr><th>Author</th><th>Email</th><th>Role</th><th>Status</th><th>Books</th><th>Joined</th><th>Actions</th></tr></thead><tbody id="authors-body"><tr><td colspan="7" class="text-muted" style="text-align:center;padding:40px;">Loading...</td></tr></tbody></table></div></div>
</div>
<!-- SUBSCRIPTIONS -->
<div class="page" id="page-subscriptions">
<div class="page-header"><h1 class="page-title">Subscriptions</h1><p class="page-sub">Active and expired subscription records</p></div>
<div class="toolbar">
<div class="search-bar"><span>🔍</span><input type="text" placeholder="Search..." id="sub-search" oninput="filterSubs()"></div>
<div class="toolbar-spacer"></div>
<button class="btn btn-secondary btn-sm" onclick="loadSubscriptions()">🔄 Refresh</button>
</div>
<div class="card" style="padding:0;"><div class="table-wrap"><table><thead><tr><th>Author</th><th>Plan</th><th>Token Budget</th><th>Granted</th><th>Expires</th><th>Status</th><th>Actions</th></tr></thead><tbody id="subs-body"><tr><td colspan="7" class="text-muted" style="text-align:center;padding:40px;">Loading...</td></tr></tbody></table></div></div>
</div>
<!-- TOKEN MANAGEMENT -->
<div class="page" id="page-token-management">
<div class="page-header flex items-center justify-between">
<div><h1 class="page-title">🪙 Token Management <span class="info-tip"><span class="tip-text">View and manage token budgets for every author. Add bonus tokens, extend subscriptions, reset usage counters, or revoke access. Token usage bars turn red when >80% consumed.</span></span></h1><p class="page-sub">Monitor and control token usage across all author subscriptions</p></div>
<button class="btn btn-secondary btn-sm" onclick="loadTokenManagement()">🔄 Refresh</button>
</div>
<!-- Summary stats -->
<div class="stat-grid" style="margin-bottom:20px;">
<div class="stat-card"><div class="stat-icon">📦</div><div class="stat-val" id="tm-total-grants" style="color:var(--indigo);"></div><div class="stat-label">Total Grants</div></div>
<div class="stat-card"><div class="stat-icon"></div><div class="stat-val green" id="tm-active-grants"></div><div class="stat-label">Active</div></div>
<div class="stat-card"><div class="stat-icon"></div><div class="stat-val yellow" id="tm-tokens-used"></div><div class="stat-label">Total Tokens Used</div></div>
<div class="stat-card"><div class="stat-icon">💰</div><div class="stat-val blue" id="tm-tokens-remaining"></div><div class="stat-label">Total Remaining</div></div>
</div>
<!-- Filter bar -->
<div class="toolbar" style="margin-bottom:16px;">
<div class="search-bar"><span>🔍</span><input type="text" placeholder="Search author..." id="tm-search" oninput="filterTokenManagement()"></div>
<div class="toolbar-spacer"></div>
<select class="form-input" style="width:auto;margin:0;padding:6px 12px;font-size:13px;" id="tm-filter" onchange="filterTokenManagement()">
<option value="all">All Grants</option>
<option value="active">Active Only</option>
<option value="expired">Expired</option>
<option value="revoked">Revoked</option>
<option value="critical">Critical (>80%)</option>
</select>
</div>
<div id="tm-grants-container"><div style="text-align:center;padding:60px;color:var(--muted);">Loading token data...</div></div>
</div>
<!-- TOKEN ACTION MODAL -->
<div id="token-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;align-items:center;justify-content:center;">
<div style="background:var(--white);border-radius:var(--radius);padding:32px;width:100%;max-width:460px;box-shadow:var(--shadow-lg);animation:scaleIn 0.2s ease;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;" id="modal-title">Action</h2>
<button onclick="closeTokenModal()" style="background:none;border:none;font-size:20px;cursor:pointer;color:var(--muted);">×</button>
</div>
<div id="modal-body"></div>
<div style="display:flex;gap:10px;margin-top:20px;" id="modal-actions"></div>
</div>
</div>
<!-- PLATFORM ANALYTICS -->
<div class="page" id="page-platform-analytics">
<div class="page-header"><h1 class="page-title">Platform Analytics <span class="info-tip"><span class="tip-text">Cross-author aggregate data: total sessions, books indexed, link clicks, and token consumption across all authors. Useful for platform growth tracking.</span></span></h1><p class="page-sub">Cross-author aggregate analytics</p></div>
<div class="stat-grid">
<div class="stat-card"><div class="stat-icon">💬</div><div class="stat-val red" id="pa-total-sessions"></div><div class="stat-label">All-Time Sessions</div></div>
<div class="stat-card"><div class="stat-icon">📚</div><div class="stat-val green" id="pa-total-books"></div><div class="stat-label">Books Indexed</div></div>
<div class="stat-card"><div class="stat-icon">🔗</div><div class="stat-val blue" id="pa-total-clicks"></div><div class="stat-label">Link Clicks</div></div>
<div class="stat-card"><div class="stat-icon"></div><div class="stat-val yellow" id="pa-tokens-used"></div><div class="stat-label">Tokens Consumed</div></div>
</div>
<div class="card"><div class="card-title">Daily Platform Activity</div><div class="chart-container"><canvas id="chart-pa"></canvas></div></div>
</div>
<!-- GRANT ACCESS -->
<div class="page" id="page-grant">
<div class="page-header"><h1 class="page-title">Grant Author Access <span class="info-tip"><span class="tip-text">Create a subscription for an author by email. Choose a plan (Starter/Pro/Enterprise/Unlimited), set token budget and duration. The author must already be registered via /admin.</span></span></h1><p class="page-sub">Create a new subscription for an author</p></div>
<div class="card" style="max-width:600px;">
<div class="form-group"><label class="form-label">Author Email</label><input type="email" class="form-input" id="g-email" placeholder="author@example.com"></div>
<div class="grid-2">
<div class="form-group"><label class="form-label">Plan</label>
<select class="form-input" id="g-plan"><option value="starter">Starter</option><option value="pro">Pro</option><option value="enterprise">Enterprise</option><option value="unlimited">Unlimited</option></select>
</div>
<div class="form-group"><label class="form-label">Duration (days)</label><input type="number" class="form-input" id="g-duration" value="30" min="1" max="365"></div>
</div>
<div class="grid-2">
<div class="form-group"><label class="form-label">Token Budget</label><input type="number" class="form-input" id="g-tokens" value="500000" min="1000"></div>
<div class="form-group"><label class="form-label">Bonus Tokens</label><input type="number" class="form-input" id="g-bonus" value="0" min="0"></div>
</div>
<div class="form-group"><label class="form-label">Notes (internal)</label><textarea class="form-input" id="g-notes" placeholder="Optional notes about this grant..."></textarea></div>
<button class="btn btn-primary btn-block" id="grant-btn">Grant Access</button>
</div>
</div>
<!-- PLAN TEMPLATES -->
<div class="page" id="page-plans">
<div class="page-header"><h1 class="page-title">Plan Templates</h1><p class="page-sub">Define reusable subscription plan configurations</p></div>
<div class="card">
<div class="card-title">Available Plans</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-top:16px;" id="plans-grid">
<div class="stat-card" style="border-left:3px solid var(--blue);"><div class="fw-700">Starter</div><div class="text-xs text-muted mt-4">100K tokens · 30 days</div><div class="text-xs text-muted">$19/mo</div></div>
<div class="stat-card" style="border-left:3px solid var(--green);"><div class="fw-700">Pro</div><div class="text-xs text-muted mt-4">500K tokens · 30 days</div><div class="text-xs text-muted">$49/mo</div></div>
<div class="stat-card" style="border-left:3px solid #8b5cf6;"><div class="fw-700">Enterprise</div><div class="text-xs text-muted mt-4">2M tokens · 90 days</div><div class="text-xs text-muted">$199/mo</div></div>
<div class="stat-card" style="border-left:3px solid var(--accent);"><div class="fw-700">Unlimited</div><div class="text-xs text-muted mt-4">Unlimited · 365 days</div><div class="text-xs text-muted">Custom</div></div>
</div>
</div>
</div>
<!-- ANNOUNCEMENTS -->
<div class="page" id="page-announcements">
<div class="page-header"><h1 class="page-title">Announcements</h1><p class="page-sub">Broadcast messages to all authors</p></div>
<div class="card" style="max-width:600px;">
<div class="card-title">New Announcement</div>
<div class="form-group mt-4"><label class="form-label">Title</label><input type="text" class="form-input" id="ann-title" placeholder="Platform Update"></div>
<div class="form-group"><label class="form-label">Message</label><textarea class="form-input" id="ann-msg" placeholder="Write your announcement..."></textarea></div>
<div class="form-group"><label class="form-label">Priority</label>
<select class="form-input" id="ann-priority"><option value="info">Info</option><option value="warning">Warning</option><option value="critical">Critical</option></select>
</div>
<button class="btn btn-primary" id="send-ann-btn">📣 Send Announcement</button>
</div>
</div>
<!-- HEALTH MONITOR -->
<div class="page" id="page-health">
<div class="page-header flex items-center justify-between"><div><h1 class="page-title">Health Monitor</h1><p class="page-sub">Real-time status of platform services</p></div><button class="btn btn-secondary btn-sm" onclick="loadHealth()">🔄 Refresh</button></div>
<div id="health-cards">
<div class="health-card"><div class="health-dot ok"></div><div><div class="fw-600">Database (SQLite)</div><div class="text-xs text-muted">Checking...</div></div></div>
<div class="health-card"><div class="health-dot ok"></div><div><div class="fw-600">Redis</div><div class="text-xs text-muted">Checking...</div></div></div>
<div class="health-card"><div class="health-dot ok"></div><div><div class="fw-600">ChromaDB</div><div class="text-xs text-muted">Checking...</div></div></div>
<div class="health-card"><div class="health-dot ok"></div><div><div class="fw-600">AI Models</div><div class="text-xs text-muted">Checking...</div></div></div>
</div>
</div>
<!-- AUDIT LOG -->
<div class="page" id="page-audit">
<div class="page-header"><h1 class="page-title">Audit Log</h1><p class="page-sub">Immutable record of all SuperAdmin actions — cannot be deleted</p></div>
<div class="toolbar">
<div class="search-bar"><span>🔍</span><input type="text" placeholder="Filter audit entries..." id="audit-search" oninput="filterAudit()"></div>
<div class="toolbar-spacer"></div>
<span class="text-xs text-muted" id="audit-count">— entries</span>
</div>
<div class="card" style="padding:0;" id="audit-container"><div style="padding:40px;text-align:center;" class="text-muted">Loading audit log...</div></div>
</div>
<!-- SETTINGS -->
<div class="page" id="page-settings">
<div class="page-header"><h1 class="page-title">Platform Settings</h1><p class="page-sub">Global configuration for the Author RAG platform</p></div>
<div class="card" style="margin-bottom:20px;">
<div class="card-title">Provider API Keys</div>
<div class="card-sub">Enter each provider key once — it applies to every platform listed below that provider. Priority: saved key → env fallback → free scraping.</div>
<div id="platform-api-list" style="display:flex;flex-direction:column;gap:12px;"></div>
</div>
<div class="card" style="max-width:600px;">
<div class="card-title">Rate Limiting</div>
<div class="card-sub">Configure global rate limits for chat API</div>
<div class="grid-2">
<div class="form-group"><label class="form-label">Max Requests/Minute</label><input type="number" class="form-input" id="set-rpm" value="30" min="1"></div>
<div class="form-group"><label class="form-label">Max Tokens/Request</label><input type="number" class="form-input" id="set-max-tokens" value="4096" min="100"></div>
</div>
<button class="btn btn-primary btn-sm">Save Settings</button>
</div>
<div class="card" style="max-width:600px;">
<div class="card-title">Platform Maintenance</div>
<div class="card-sub">Put the platform in maintenance mode</div>
<div class="flex items-center gap-3">
<label style="position:relative;width:44px;height:24px;cursor:pointer;"><input type="checkbox" id="set-maintenance" style="display:none;"><div style="position:absolute;inset:0;background:var(--border);border-radius:12px;transition:background 0.2s;"></div></label>
<span class="text-sm">Maintenance Mode</span>
</div>
</div>
</div>
<!-- ??? AUTHOR CONFIG PAGES ???????????????????????????????????????????????
All pages below operate on a SELECTED AUTHOR (set via Select Author page).
SuperAdmin calls the same /api/admin/{slug}/... endpoints on behalf of the author.
??????????????????????????????????????????????????????????????????????????? -->
<!-- ?? Select Author ???????????????????????????????????????????????????? -->
<div class="page" id="page-author-select">
<div class="page-header"><h2 class="page-title">🎯 Select Author to Configure</h2><p class="page-sub">Choose an author to manage their growth &amp; enterprise settings on their behalf.</p></div>
<div class="card">
<div class="form-group">
<label class="form-label">Search Author</label>
<input class="form-input" id="sa-author-search" placeholder="Type email or name…" oninput="filterSaAuthors(this.value)">
</div>
<div id="sa-author-list" style="display:grid;gap:10px;margin-top:16px;"></div>
<div id="sa-selected-banner" style="display:none;margin-top:16px;padding:12px 16px;background:var(--indigo-soft);border:1.5px solid rgba(99,102,241,0.25);border-radius:var(--radius-sm);font-size:13px;font-weight:600;">
🎯 Working on: <span id="sa-selected-name" style="color:var(--indigo);"></span>
</div>
</div>
</div>
<!-- ?? A/B Tests ????????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-abtests">
<div class="page-header"><h2 class="page-title">🧪 A/B Tests</h2><p class="page-sub">Configure A/B tests for the selected author's chatbot. <span id="sa-abtests-for" class="sa-for-badge"></span></p></div>
<div id="sa-abtests-content"><div class="sa-no-author-msg"></div></div>
</div>
<!-- ?? Webhooks ?????????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-webhooks">
<div class="page-header"><h2 class="page-title">🔔 Webhooks</h2><p class="page-sub">Manage webhook endpoints for <span id="sa-webhooks-for" class="sa-for-badge"></span></p></div>
<div class="card" id="sa-webhooks-content">
<div id="sa-webhooks-list"><div class="sa-no-author-msg"></div></div>
<div id="sa-webhook-form" style="display:none;margin-top:20px;">
<div class="form-group"><label class="form-label">Endpoint URL</label><input class="form-input" id="sa-wh-url" placeholder="https://�"></div>
<div class="form-group"><label class="form-label">Events (comma-separated)</label><input class="form-input" id="sa-wh-events" value="buy_intent,conversation_start,link_click"></div>
<button class="btn btn-primary" onclick="saveSaWebhook()">Add Webhook</button>
</div>
</div>
<button class="btn btn-secondary" style="margin-top:16px;" onclick="document.getElementById('sa-webhook-form').style.display='block'">+ Add Endpoint</button>
</div>
<!-- ?? Referral ?????????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-referral">
<div class="page-header"><h2 class="page-title">🎁 Referral Program</h2><p class="page-sub">Manage referral links &amp; commissions for <span id="sa-referral-for" class="sa-for-badge"></span></p></div>
<div id="sa-referral-content"><div class="sa-no-author-msg"></div></div>
</div>
<!-- ?? White-Label ??????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-whitelabel">
<div class="page-header"><h2 class="page-title">🏷️ White-Label</h2><p class="page-sub">Configure branding &amp; custom domain for <span id="sa-wl-for" class="sa-for-badge"></span></p></div>
<div class="card" id="sa-wl-content">
<div class="sa-no-author-msg" id="sa-wl-msg"></div>
<div id="sa-wl-form" style="display:none;">
<div class="form-group"><label class="form-label">Custom Domain</label><input class="form-input" id="sa-wl-domain" placeholder="chat.author-domain.com"></div>
<div class="form-group" style="display:flex;align-items:center;gap:12px;"><input type="checkbox" id="sa-wl-branding"><label class="form-label" style="margin:0;">Remove "Powered by" branding</label></div>
<div class="form-group"><label class="form-label">Custom Widget CSS</label><textarea class="form-input" id="sa-wl-css" rows="5" placeholder="/* custom CSS */"></textarea></div>
<button class="btn btn-primary" onclick="saveSaWhitelabel()">Save White-Label Settings</button>
</div>
</div>
</div>
<!-- ?? API Keys ?????????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-apikeys">
<div class="page-header"><h2 class="page-title">🔑 API Keys</h2><p class="page-sub">Manage public API keys for <span id="sa-apikeys-for" class="sa-for-badge"></span></p></div>
<div id="sa-apikeys-content"><div class="sa-no-author-msg"></div></div>
</div>
<!-- ?? Email Integration ????????????????????????????????????????????????? -->
<div class="page" id="page-sa-email">
<div class="page-header"><h2 class="page-title">📧 Email Integration</h2><p class="page-sub">Configure ConvertKit/Mailchimp sync for <span id="sa-email-for" class="sa-for-badge"></span></p></div>
<div class="card" id="sa-email-content">
<div class="sa-no-author-msg" id="sa-email-msg"></div>
<div id="sa-email-form" style="display:none;">
<div class="form-group"><label class="form-label">Provider</label><select class="form-input" id="sa-ei-provider" style="max-width:200px;"><option value="convertkit">ConvertKit</option><option value="mailchimp">Mailchimp</option></select></div>
<div class="form-group"><label class="form-label">API Key</label><input class="form-input" id="sa-ei-apikey" type="password" placeholder="Paste API key"></div>
<div class="form-group"><label class="form-label">List / Form ID</label><input class="form-input" id="sa-ei-listid" placeholder="123456"></div>
<div class="form-group" style="display:flex;gap:16px;font-size:13px;">
<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" id="sa-ei-intent" checked> On buy intent</label>
<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" id="sa-ei-click"> On link click</label>
</div>
<div style="display:flex;gap:10px;">
<button class="btn btn-primary" onclick="saveSaEmail()">Save</button>
<button class="btn btn-secondary" onclick="testSaEmail()">Test Connection</button>
</div>
</div>
</div>
</div>
<!-- ?? Agency ???????????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-agency">
<div class="page-header"><h2 class="page-title">? Agency</h2><p class="page-sub">Manage agency account and sub-seats for <span id="sa-agency-for" class="sa-for-badge"></span></p></div>
<div id="sa-agency-content"><div class="sa-no-author-msg"></div></div>
</div>
<!-- ?? SSO ??????????????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-sso">
<div class="page-header"><h2 class="page-title">🛡️ SSO Configuration</h2><p class="page-sub">Configure SAML 2.0 SSO for <span id="sa-sso-for" class="sa-for-badge"></span></p></div>
<div class="card" id="sa-sso-content">
<div class="sa-no-author-msg" id="sa-sso-msg"></div>
<div id="sa-sso-form" style="display:none;">
<div class="form-group"><label class="form-label">IdP Metadata URL</label><input class="form-input" id="sa-sso-idp" placeholder="https://idp.example.com/metadata"></div>
<div class="form-group"><label class="form-label">Email Attribute</label><input class="form-input" id="sa-sso-attr-email" value="email"></div>
<div class="form-group"><label class="form-label">Name Attribute</label><input class="form-input" id="sa-sso-attr-name" value="displayName"></div>
<div class="form-group" style="display:flex;align-items:center;gap:12px;"><input type="checkbox" id="sa-sso-autoprovision" checked><label class="form-label" style="margin:0;">Auto-provision accounts on first SSO login</label></div>
<div style="display:flex;gap:10px;">
<button class="btn btn-primary" onclick="saveSaSSO()">Save SSO Config</button>
<button class="btn btn-secondary" onclick="window.open('/sso/'+saSelectedSlug+'/metadata','_blank')">📋 SP Metadata</button>
</div>
</div>
</div>
</div>
<!-- ?? GDPR ?????????????????????????????????????????????????????????????? -->
<div class="page" id="page-sa-gdpr">
<div class="page-header"><h2 class="page-title">⚖️ GDPR / Data</h2><p class="page-sub">Data management actions for <span id="sa-gdpr-for" class="sa-for-badge"></span></p></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;">
<div class="card"><div class="card-title" style="font-size:14px;">📥 Export Author Data</div><p style="font-size:13px;color:var(--muted);margin:8px 0 14px;">Download full JSON export of this author's data.</p><button class="btn btn-secondary" onclick="saExportData()">Download Export</button></div>
<div class="card"><div class="card-title" style="font-size:14px;">🕵️‍♂️ Anonymise Conversations</div><p style="font-size:13px;color:var(--muted);margin:8px 0 14px;">Replace visitor messages with [ANONYMISED].</p><button class="btn btn-secondary" onclick="saAnonymise()">Anonymise</button></div>
</div>
<div class="card" style="border:1.5px solid rgba(239,68,68,0.3);">
<div class="card-title" style="color:var(--red);font-size:14px;">🗑️ Schedule Account Deletion</div>
<p style="font-size:13px;color:var(--muted);margin:8px 0 14px;">Schedule author account for deletion in 30 days (GDPR Art. 17).</p>
<div id="sa-gdpr-status" style="display:none;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:13px;margin-bottom:12px;"></div>
<button class="btn" style="background:var(--red);color:#fff;" onclick="saRequestDeletion()">Schedule Deletion</button>
</div>
</div>
</main>
</div>
<script>
// SuperAdmin auth endpoints live at /api/auth/*
// SuperAdmin data endpoints live at /api/super/*
// auth-client must use /api so refresh/me/logout all resolve correctly.
const AUTH_API = window.location.origin + '/api';
const DATA_API = window.location.origin + '/api';
const auth = new AuthorBotAuthClient({
apiBase: AUTH_API,
accessKey: 'sa_token',
refreshKey: 'sa_refresh_token',
});
const shell = new SuperAdminShell(auth, DATA_API);
let token = auth.getAccessToken();
let allAuthors = [];
let allAuditEntries = [];
let chartInstances = {};
/* TOAST */
function toast(msg, type = 'info') {
const c = document.getElementById('toast-container');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<span>${{success:'✅',error:'❌',info:'ℹ️'}[type]||'ℹ️'}</span><span>${msg}</span><button class="toast-close" onclick="this.parentElement.remove()">×</button>`;
c.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 4000);
}
const showToast = toast;
/* MODAL */
function showModal(title, body, actions = '') {
document.getElementById('modal-content').innerHTML = `<div class="modal-title">${title}</div>${body}${actions ? `<div class="modal-actions">${actions}</div>` : ''}`;
document.getElementById('modal-overlay').classList.add('active');
}
function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
/* AUTH */
function clearAuthorSessionKeys() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('author_slug');
}
shell.onDashboardReady = function () {
token = auth.getAccessToken();
loadOverview();
};
shell.boot(function (user) { return user.role === 'superadmin'; }).then(function () {
/* boot handles screens */
}).catch(function () {
shell.showScreen('auth-screen');
});
document.getElementById('sa-login-btn').onclick = async () => {
const msg = document.getElementById('sa-login-msg');
msg.textContent = ''; msg.style.color = '';
const btn = document.getElementById('sa-login-btn');
btn.disabled = true; btn.textContent = 'Authenticating...';
try {
const data = await auth.login(
document.getElementById('sa-user').value,
document.getElementById('sa-pass').value
);
if (!data.is_superadmin) {
auth.clearSession();
clearAuthorSessionKeys();
throw new Error('Not a SuperAdmin account — use /admin for author login');
}
clearAuthorSessionKeys();
token = auth.getAccessToken();
toast('Welcome, SuperAdmin', 'success');
await shell.checkTotpAndEnter();
} catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
finally { btn.disabled = false; btn.textContent = 'Authenticate'; }
};
['sa-user', 'sa-pass'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('sa-login-btn').click(); }));
document.getElementById('sa-logout-btn').onclick = function () {
shell.logout();
};
document.getElementById('totp-unlock-btn').onclick = async () => {
const msg = document.getElementById('totp-unlock-msg');
const code = document.getElementById('totp-unlock-code').value.trim();
msg.textContent = '';
if (code.length !== 6) { msg.textContent = 'Enter the 6-digit code'; msg.style.color = 'var(--red)'; return; }
const btn = document.getElementById('totp-unlock-btn');
btn.disabled = true;
try {
await shell.unlockWithCode(code);
toast('Verified — welcome back', 'success');
} catch (e) {
msg.textContent = e.message;
msg.style.color = 'var(--red)';
} finally {
btn.disabled = false;
}
};
document.getElementById('totp-confirm-btn').onclick = async () => {
const msg = document.getElementById('totp-setup-msg');
const code = document.getElementById('totp-enroll-code').value.trim();
msg.textContent = '';
if (code.length !== 6) { msg.textContent = 'Enter the 6-digit code from your app'; msg.style.color = 'var(--red)'; return; }
const btn = document.getElementById('totp-confirm-btn');
btn.disabled = true;
try {
await shell.apiJwt('/super/totp/confirm', 'POST', { code });
toast('Two-factor authentication enabled', 'success');
shell.showTotpUnlock();
} catch (e) {
msg.textContent = e.message;
msg.style.color = 'var(--red)';
} finally {
btn.disabled = false;
}
};
['totp-unlock-code'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('totp-unlock-btn').click(); });
});
/* API — privileged calls use shell (JWT + TOTP session) */
async function apiGet(path) { return shell.apiData('GET', path); }
async function apiPost(path, body) { return shell.apiData('POST', path, body); }
async function apiPut(path, body) { return shell.apiData('PUT', path, body); }
async function apiDelete(path) { return shell.apiData('DELETE', path); }
function nav(page) {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const btn = document.querySelector(`.nav-item[data-page="${page}"]`);
if (btn) btn.classList.add('active');
const pg = document.getElementById('page-' + page);
if (pg) pg.classList.add('active');
const loaders = {
authors: loadAuthors,
subscriptions: loadSubscriptions,
'token-management': loadTokenManagement,
audit: loadAudit,
health: loadHealth,
'platform-analytics': loadPlatformAnalytics,
settings: loadPlatformApis,
'author-select': loadAuthorSelect,
'sa-webhooks': loadSaWebhooks,
'sa-whitelabel': loadSaWhitelabel,
};
if (loaders[page]) loaders[page]();
}
document.querySelectorAll('.nav-item[data-page]').forEach(el => { el.onclick = () => nav(el.dataset.page); });
function animateNumber(id, target) {
const el = document.getElementById(id); if (!el) return;
const start = performance.now();
function step(now) { const p = Math.min((now - start) / 600, 1); el.textContent = Math.round(target * (1 - Math.pow(1 - p, 3))).toLocaleString(); if (p < 1) requestAnimationFrame(step); }
requestAnimationFrame(step);
}
/* OVERVIEW */
async function loadOverview() {
try {
const data = await apiGet('/super/authors?limit=500');
const authors = data.clients || [];
animateNumber('sa-total-authors', authors.length);
animateNumber('sa-active-subs', authors.filter(a => !a.suspended).length);
animateNumber('sa-total-books', authors.reduce((s, a) => s + (a.book_count || 0), 0));
animateNumber('sa-total-sessions', Math.floor(Math.random() * 50));
} catch(e) { console.error('Overview:', e); }
}
/* AUTHORS */
async function loadAuthors() {
const tbody = document.getElementById('authors-body');
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;"><div class="skeleton" style="height:20px;width:60%;margin:0 auto;"></div></td></tr>';
try {
const data = await apiGet('/super/authors?limit=500');
allAuthors = data.clients || [];
document.getElementById('author-count').textContent = `${allAuthors.length} authors`;
renderAuthors(allAuthors);
} catch(e) { tbody.innerHTML = `<tr><td colspan="7" style="color:var(--red);text-align:center;padding:40px;">${e.message}</td></tr>`; }
}
function renderAuthors(list) {
const tbody = document.getElementById('authors-body');
if (!list.length) { tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="empty-icon">👥</div><div class="empty-title">No authors found</div></div></td></tr>'; return; }
tbody.innerHTML = list.map(a => `<tr>
<td class="fw-600">${esc(a.full_name || '—')}</td>
<td class="text-sm">${esc(a.email)}</td>
<td><span class="badge badge-info">${a.role || 'author'}</span></td>
<td>${a.suspended ? '<span class="badge badge-suspended">Suspended</span>' : '<span class="badge badge-active">Active</span>'}</td>
<td>${a.book_count || 0}</td>
<td class="text-xs text-muted">${a.created_at ? new Date(a.created_at).toLocaleDateString() : '—'}</td>
<td>
${a.suspended
? `<button class="btn btn-success btn-sm" onclick="toggleSuspend('${a.id}', false)">Unsuspend</button>`
: `<button class="btn btn-danger btn-sm" onclick="toggleSuspend('${a.id}', true)">Suspend</button>`}
<button class="btn btn-secondary btn-sm" onclick="viewAuthorDetail('${a.id}')">Details</button>
<button class="btn btn-danger btn-sm" onclick="confirmDeleteAuthor('${a.id}','${esc(a.email)}')" title="Permanently delete this author">Remove</button>
</td>
</tr>`).join('');
}
function filterAuthors() {
const q = document.getElementById('author-search').value.toLowerCase();
renderAuthors(allAuthors.filter(a => (a.email||'').toLowerCase().includes(q) || (a.full_name||'').toLowerCase().includes(q)));
}
async function toggleSuspend(id, suspend) {
if (suspend) {
showModal('Suspend Author', `<div class="modal-sub">This will immediately revoke all API access for this author.</div><div class="form-group"><label class="form-label">Reason</label><input type="text" class="form-input" id="suspend-reason" placeholder="Policy violation, etc."></div>`,
`<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="executeSuspend('${id}')">Suspend</button>`);
} else {
try { await apiPost(`/super/authors/${id}/unsuspend`, {}); toast('Author unsuspended', 'success'); loadAuthors(); } catch(e) { toast(e.message, 'error'); }
}
}
async function executeSuspend(id) {
const reason = document.getElementById('suspend-reason').value || 'No reason given';
try { await apiPost(`/super/authors/${id}/suspend?reason=${encodeURIComponent(reason)}`, {}); closeModal(); toast('Author suspended', 'success'); loadAuthors(); } catch(e) { toast(e.message, 'error'); }
}
function confirmDeleteAuthor(id, email) {
showModal('Remove Author',
`<div class="modal-sub">Permanently delete <strong>${email}</strong>? This removes all books, chat history, embeddings, and subscriptions. <strong>This cannot be undone.</strong></div>
<div class="form-group"><label class="form-label">Type DELETE to confirm</label><input type="text" class="form-input" id="delete-author-confirm" placeholder="DELETE"></div>`,
`<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="executeDeleteAuthor('${id}')">Remove Author</button>`);
}
async function executeDeleteAuthor(id) {
const input = document.getElementById('delete-author-confirm');
if (!input || input.value.trim().toUpperCase() !== 'DELETE') {
toast('Type DELETE to confirm removal', 'error');
return;
}
try {
await apiDelete(`/super/authors/${id}`);
closeModal();
toast('Author removed', 'success');
loadAuthors();
if (typeof loadOverview === 'function') loadOverview();
} catch(e) { toast(e.message, 'error'); }
}
function viewAuthorDetail(id) {
const a = allAuthors.find(x => x.id === id);
if (!a) return;
showModal(`Author: ${esc(a.full_name || a.email)}`,
`<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px;">
<div><span class="text-muted">Email:</span> ${esc(a.email)}</div>
<div><span class="text-muted">Role:</span> ${a.role || 'author'}</div>
<div><span class="text-muted">Status:</span> ${a.suspended ? '🔴 Suspended' : '🟢 Active'}</div>
<div><span class="text-muted">Books:</span> ${a.book_count || 0}</div>
<div><span class="text-muted">Plan:</span> ${a.plan || '—'}</div>
<div><span class="text-muted">Expires:</span> ${a.expires_at ? new Date(a.expires_at).toLocaleDateString() : '—'}</div>
</div>`,
`<button class="btn btn-secondary" onclick="closeModal()">Close</button>`);
}
/* CREATE AUTHOR */
function showCreateAuthorModal() {
showModal('Create New Author',
`<div class="modal-sub">Register a new author account. Optionally assign a subscription plan immediately.</div>
<div class="form-group"><label class="form-label">Full Name</label><input type="text" class="form-input" id="ca-name" placeholder="Jane Author"></div>
<div class="form-group"><label class="form-label">Email</label><input type="email" class="form-input" id="ca-email" placeholder="author@example.com"></div>
<div class="form-group"><label class="form-label">Password</label><input type="password" class="form-input" id="ca-pass" placeholder="Min 8 chars, uppercase, digit, special char"></div>
<div class="grid-2">
<div class="form-group"><label class="form-label">Plan (optional)</label>
<select class="form-input" id="ca-plan">
<option value="">No plan — register only</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="semi_annual">Semi-Annual</option>
<option value="annual">Annual</option>
</select>
</div>
<div class="form-group"><label class="form-label">Auto-Renew</label>
<select class="form-input" id="ca-renew"><option value="false">No</option><option value="true">Yes</option></select>
</div>
</div>`,
`<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="executeCreateAuthor()">Create Author</button>`);
}
async function executeCreateAuthor() {
const name = document.getElementById('ca-name').value.trim();
const email = document.getElementById('ca-email').value.trim();
const pass = document.getElementById('ca-pass').value;
const plan = document.getElementById('ca-plan').value || null;
const autoRenew = document.getElementById('ca-renew').value === 'true';
if (!name || !email || !pass) { toast('Please fill in all required fields', 'error'); return; }
if (pass.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
// Disable button during request
const btn = document.querySelector('#modal-content .btn-primary');
if (btn) { btn.disabled = true; btn.textContent = 'Creating...'; }
try {
const body = { email, password: pass, full_name: name };
if (plan) { body.plan = plan; body.auto_renew = autoRenew; }
const result = await apiPost('/super/authors', body);
closeModal();
// Show success confirmation with next steps
const hasSub = result.subscription;
const subInfo = hasSub
? `<div style="margin-top:16px;padding:14px;background:var(--green-bg);border-radius:var(--radius-sm);font-size:13px;">
<strong>✅ Subscription Assigned</strong><br>
Plan: <strong>${plan}</strong> · Auto-renew: ${autoRenew ? 'Yes' : 'No'}<br>
${result.subscription.expires_at ? 'Expires: ' + new Date(result.subscription.expires_at).toLocaleDateString() : ''}
</div>`
: `<div style="margin-top:16px;padding:14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:13px;">
<strong>⚠️ No subscription assigned yet.</strong><br>
The author needs a subscription plan before their chatbot can go live.
</div>`;
showModal('✅ Author Created Successfully',
`<div style="font-size:14px;line-height:1.8;">
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;margin-top:12px;">
<span class="text-muted">Name:</span> <strong>${esc(name)}</strong>
<span class="text-muted">Email:</span> <strong>${esc(email)}</strong>
<span class="text-muted">Author ID:</span> <code style="font-size:11px;background:var(--surface2);padding:2px 8px;border-radius:4px;">${result.author_id || '—'}</code>
</div>
${subInfo}
<div style="margin-top:16px;padding:12px;background:var(--surface2);border-radius:var(--radius-sm);font-size:12px;color:var(--muted);">
<strong>Login credentials:</strong> The author can now sign in at <code>/admin</code> with the email and password you just set.
</div>
</div>`,
hasSub
? `<button class="btn btn-secondary" onclick="closeModal()">Done</button>
<button class="btn btn-primary" onclick="closeModal(); nav('authors');">View Authors</button>`
: `<button class="btn btn-secondary" onclick="closeModal()">Skip for Now</button>
<button class="btn btn-primary" onclick="closeModal(); prefillGrant('${esc(email)}');">Assign Subscription →</button>`
);
loadAuthors();
loadOverview();
} catch(e) {
console.error('Create author error:', e);
if (btn) { btn.disabled = false; btn.textContent = 'Create Author'; }
toast(e.message || 'Failed to create author', 'error');
}
}
function prefillGrant(email) {
nav('grant');
setTimeout(function() {
const el = document.getElementById('g-email');
if (el) { el.value = email; el.focus(); }
}, 100);
}
/* SUBSCRIPTIONS — now powered by /super/grants */
let allGrants = [];
async function loadSubscriptions() {
const tbody = document.getElementById('subs-body');
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;"><div class="skeleton" style="height:20px;width:60%;margin:0 auto;"></div></td></tr>';
try {
const data = await apiGet('/super/grants?limit=200');
allGrants = data.grants || [];
renderSubs(allGrants);
} catch(e) { tbody.innerHTML = `<tr><td colspan="7" style="color:var(--red);text-align:center;padding:30px;">${e.message}</td></tr>`; }
}
function renderSubs(list) {
const tbody = document.getElementById('subs-body');
if (!list.length) { tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="empty-icon">💳</div><div class="empty-title">No subscriptions yet</div></div></td></tr>'; return; }
tbody.innerHTML = list.map(g => `<tr>
<td><div class="fw-600 text-sm">${esc(g.author_name || g.author_email)}</div><div class="text-xs text-muted">${esc(g.author_email)}</div></td>
<td><span class="badge badge-info">${g.plan || '—'}</span></td>
<td>${(g.token_budget || 0).toLocaleString()}</td>
<td class="text-xs text-muted">${g.granted_at ? new Date(g.granted_at).toLocaleDateString() : '—'}</td>
<td class="text-xs text-muted">${g.expires_at ? new Date(g.expires_at).toLocaleDateString() : '—'}</td>
<td>${g.is_revoked ? '<span class="badge badge-suspended">Revoked</span>' : g.is_expired ? '<span class="badge" style="background:var(--yellow-bg);color:var(--yellow);">Expired</span>' : '<span class="badge badge-active">Active</span>'}</td>
<td style="display:flex;gap:4px;flex-wrap:wrap;">
<button class="btn btn-secondary btn-sm" onclick="openExtendModal('${g.grant_id}','${esc(g.author_email)}')">+Days</button>
<button class="btn btn-secondary btn-sm" onclick="openBonusModal('${g.grant_id}','${esc(g.author_email)}')">+Tokens</button>
${!g.is_revoked ? `<button class="btn btn-danger btn-sm" onclick="openRevokeModal('${g.grant_id}','${esc(g.author_email)}')">Revoke</button>` : ''}
</td>
</tr>`).join('');
}
function filterSubs() {
const q = document.getElementById('sub-search').value.toLowerCase();
renderSubs(allGrants.filter(g => (g.author_email||'').toLowerCase().includes(q) || (g.author_name||'').toLowerCase().includes(q)));
}
/* TOKEN MANAGEMENT */
async function loadTokenManagement() {
const container = document.getElementById('tm-grants-container');
container.innerHTML = '<div style="text-align:center;padding:60px;"><div class="skeleton" style="height:24px;width:50%;margin:0 auto 12px;"></div><div class="skeleton" style="height:24px;width:70%;margin:0 auto;"></div></div>';
try {
const data = await apiGet('/super/grants?limit=200');
allGrants = data.grants || [];
// Stats
const active = allGrants.filter(g => g.is_active);
const totalUsed = allGrants.reduce((s, g) => s + (g.tokens_used || 0), 0);
const totalRemaining = allGrants.reduce((s, g) => s + (g.tokens_remaining || 0), 0);
animateNumber('tm-total-grants', allGrants.length);
animateNumber('tm-active-grants', active.length);
animateNumber('tm-tokens-used', totalUsed);
animateNumber('tm-tokens-remaining', totalRemaining);
renderTokenGrants(allGrants);
} catch(e) { container.innerHTML = `<div style="padding:40px;color:var(--red);text-align:center;">${e.message}</div>`; }
}
function renderTokenGrants(list) {
const container = document.getElementById('tm-grants-container');
const filter = document.getElementById('tm-filter')?.value || 'all';
let filtered = list;
if (filter === 'active') filtered = list.filter(g => g.is_active);
else if (filter === 'expired') filtered = list.filter(g => g.is_expired && !g.is_revoked);
else if (filter === 'revoked') filtered = list.filter(g => g.is_revoked);
else if (filter === 'critical') filtered = list.filter(g => g.usage_pct >= 80);
if (!filtered.length) { container.innerHTML = '<div style="text-align:center;padding:60px;color:var(--muted);"><div style="font-size:32px;">🪙</div><div style="font-size:15px;margin-top:12px;">No grants match this filter</div></div>'; return; }
container.innerHTML = filtered.map(g => {
const pct = g.usage_pct || 0;
const barColor = pct >= 90 ? 'var(--red)' : pct >= 80 ? 'var(--yellow)' : 'var(--green)';
const statusBadge = g.is_revoked ? '<span class="badge badge-suspended">Revoked</span>' : g.is_expired ? '<span class="badge" style="background:var(--yellow-bg);color:var(--yellow);">Expired</span>' : '<span class="badge badge-active">Active</span>';
return `
<div class="card" style="margin-bottom:12px;border-left:3px solid ${barColor};">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;">
<div style="flex:1;min-width:200px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<div class="fw-700 text-sm">${esc(g.author_name || g.author_email)}</div>
${statusBadge}
<span class="badge badge-info" style="font-size:10px;">${g.plan || '—'}</span>
</div>
<div class="text-xs text-muted">${esc(g.author_email)}</div>
<div class="text-xs text-muted" style="margin-top:2px;">Expires: ${g.expires_at ? new Date(g.expires_at).toLocaleDateString() : '—'} · Grant: ${g.grant_id?.slice(0,8)}…</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="btn btn-secondary btn-sm" onclick="openExtendModal('${g.grant_id}','${esc(g.author_email)}')" title="Add more days">📅 Extend</button>
<button class="btn btn-secondary btn-sm" onclick="openBonusModal('${g.grant_id}','${esc(g.author_email)}')" title="Add bonus tokens">⚡ Bonus</button>
<button class="btn btn-secondary btn-sm" onclick="openResetModal('${g.grant_id}','${esc(g.author_email)}')" title="Reset token usage to 0">🔄 Reset</button>
${!g.is_revoked ? `<button class="btn btn-danger btn-sm" onclick="openRevokeModal('${g.grant_id}','${esc(g.author_email)}')">🚫 Revoke</button>` : ''}
</div>
</div>
<!-- Token bar -->
<div style="margin-top:14px;">
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:4px;">
<span>Token Usage: <strong style="color:${barColor}">${pct}%</strong></span>
<span>${(g.tokens_used||0).toLocaleString()} / ${(g.total_budget||0).toLocaleString()} tokens${g.bonus_tokens ? ` (+${g.bonus_tokens.toLocaleString()} bonus)` : ''}</span>
</div>
<div style="height:8px;background:var(--surface2);border-radius:4px;overflow:hidden;">
<div style="height:100%;width:${Math.min(pct,100)}%;background:${barColor};border-radius:4px;transition:width 0.6s ease;"></div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">${(g.tokens_remaining||0).toLocaleString()} tokens remaining</div>
</div>
</div>`;
}).join('');
}
function filterTokenManagement() {
const q = document.getElementById('tm-search').value.toLowerCase();
let filtered = allGrants.filter(g => (g.author_email||'').toLowerCase().includes(q) || (g.author_name||'').toLowerCase().includes(q));
renderTokenGrants(filtered);
}
/* TOKEN MODAL HELPERS */
function closeTokenModal() { document.getElementById('token-modal').style.display = 'none'; }
function openTokenModal(title, body, onConfirm, confirmLabel = 'Confirm', confirmClass = 'btn-primary') {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').innerHTML = body;
const acts = document.getElementById('modal-actions');
acts.innerHTML = `<button class="btn btn-secondary" id="modal-cancel-btn">Cancel</button><button class="btn ${confirmClass}" id="modal-confirm-btn">${confirmLabel}</button>`;
document.getElementById('modal-cancel-btn').onclick = closeTokenModal;
document.getElementById('modal-confirm-btn').onclick = async () => {
const btn = document.getElementById('modal-confirm-btn');
const cancelBtn = document.getElementById('modal-cancel-btn');
const original = btn.textContent;
btn.disabled = true;
cancelBtn.disabled = true;
btn.textContent = '⏳ Working...';
try {
await onConfirm();
} catch (e) {
toast(e.message || 'Action failed', 'error');
} finally {
btn.disabled = false;
cancelBtn.disabled = false;
btn.textContent = original;
}
};
document.getElementById('token-modal').style.display = 'flex';
}
function openExtendModal(grantId, email) {
openTokenModal('Extend Subscription',
`<div style="margin-bottom:14px;font-size:13px;color:var(--muted);">Author: <strong>${esc(email)}</strong></div>
<div class="form-group"><label class="form-label">Additional Days</label><input type="number" class="form-input" id="tm-extend-days" value="30" min="1" max="365"></div>
<div style="font-size:12px;color:var(--muted);margin-top:8px;">💡 If the subscription has expired, it will be extended from today.</div>`,
async () => {
const days = parseInt(document.getElementById('tm-extend-days').value) || 30;
await apiPost(`/super/grants/${grantId}/extend`, { extend_days: days });
toast(`Extended by ${days} days ✅`, 'success');
closeTokenModal(); loadTokenManagement(); loadSubscriptions();
}, '📅 Extend Subscription'
);
}
function openBonusModal(grantId, email) {
openTokenModal('Add Bonus Tokens',
`<div style="margin-bottom:14px;font-size:13px;color:var(--muted);">Author: <strong>${esc(email)}</strong></div>
<div class="form-group"><label class="form-label">Bonus Tokens to Add</label><input type="number" class="form-input" id="tm-bonus-tokens" value="100000" min="1000" step="1000"></div>
<div style="font-size:12px;color:var(--muted);margin-top:8px;">💡 Bonus tokens add to the existing budget without changing expiry.</div>`,
async () => {
const tokens = parseInt(document.getElementById('tm-bonus-tokens').value) || 100000;
await apiPost(`/super/grants/${grantId}/bonus-tokens`, { bonus_tokens: tokens });
toast(`${tokens.toLocaleString()} bonus tokens added ✅`, 'success');
closeTokenModal(); loadTokenManagement();
}, '⚡ Add Bonus Tokens'
);
}
function openResetModal(grantId, email) {
openTokenModal('Reset Token Usage',
`<div style="margin-bottom:14px;font-size:13px;color:var(--muted);">Author: <strong>${esc(email)}</strong></div>
<div style="background:var(--yellow-bg);border:1px solid var(--yellow);border-radius:8px;padding:12px;font-size:13px;color:var(--text2);">⚠️ This will set <strong>tokens_used back to 0</strong> and clear any bonus tokens. The author gets a fresh token budget for the same subscription period.</div>`,
async () => {
await apiPost(`/super/grants/${grantId}/reset-tokens`, {});
toast('Token usage reset to 0 ✅', 'success');
closeTokenModal(); loadTokenManagement();
}, '🔄 Reset Usage', 'btn-secondary'
);
}
function openRevokeModal(grantId, email) {
openTokenModal('Revoke Subscription',
`<div style="margin-bottom:14px;font-size:13px;color:var(--muted);">Author: <strong>${esc(email)}</strong></div>
<div style="background:var(--red-bg);border:1px solid var(--red);border-radius:8px;padding:12px;font-size:13px;color:var(--text2);margin-bottom:14px;">🚫 Revocation is <strong>immediate</strong>. The author loses bot access instantly via Redis blacklist.</div>
<div class="form-group"><label class="form-label">Reason (required)</label><input type="text" class="form-input" id="tm-revoke-reason" placeholder="Policy violation, non-payment, etc."></div>`,
async () => {
const reason = document.getElementById('tm-revoke-reason').value.trim();
if (!reason) { toast('Please enter a reason', 'error'); return; }
if (reason.length < 5) { toast('Reason must be at least 5 characters', 'error'); return; }
await apiPost(`/super/grants/${grantId}/revoke`, { reason });
toast('Subscription revoked ✅', 'success');
closeTokenModal(); loadTokenManagement(); loadSubscriptions();
}, '🚫 Revoke Now', 'btn-danger'
);
}
document.getElementById('token-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeTokenModal(); });
/* GRANT ACCESS */
document.getElementById('grant-btn').onclick = async () => {
const email = document.getElementById('g-email').value.trim();
if (!email) { toast('Author email is required', 'error'); return; }
const btn = document.getElementById('grant-btn');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '⏳ Granting access...';
try {
const data = await apiPost('/super/grant', {
author_email: email,
plan: document.getElementById('g-plan').value,
duration_days: parseInt(document.getElementById('g-duration').value),
token_budget: parseInt(document.getElementById('g-tokens').value),
bonus_tokens: parseInt(document.getElementById('g-bonus').value) || 0,
notes: document.getElementById('g-notes').value,
});
// Show the token in a modal so admin can copy it
const tok = data.token || '';
showModal('✅ Access Granted!',
`<div style="margin-bottom:12px;font-size:14px;">Access granted to <strong>${esc(email)}</strong></div>
<div style="margin-bottom:8px;font-size:13px;font-weight:600;color:var(--muted);">Subscription Token (share with author):</div>
<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>
<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>`,
`<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>`
);
// Clear form
document.getElementById('g-email').value = '';
document.getElementById('g-notes').value = '';
document.getElementById('g-tokens').value = '500000';
document.getElementById('g-bonus').value = '0';
} catch(e) {
toast(e.message || 'Grant failed', 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
};
/* PLATFORM ANALYTICS */
async function loadPlatformAnalytics() {
try {
const data = await apiGet('/super/authors?limit=500');
const authors = data.clients || [];
const totalBooks = authors.reduce((s, a) => s + (a.book_count || 0), 0);
animateNumber('pa-total-sessions', data.count || authors.length);
animateNumber('pa-total-books', totalBooks);
animateNumber('pa-total-clicks', 0);
animateNumber('pa-tokens-used', 0);
} catch(e) { console.error('Platform analytics:', e); }
}
/* HEALTH */
async function loadHealth() {
const cards = document.querySelectorAll('.health-card');
const keys = ['database', 'redis', 'chromadb', null];
try {
const data = await apiGet('/super/health');
const checks = data.checks || {};
cards.forEach((card, i) => {
const key = keys[i];
const ok = key ? String(checks[key] || '').startsWith('ok') : true;
card.querySelector('.health-dot').className = `health-dot ${ok ? 'ok' : 'err'}`;
card.querySelector('.text-xs').textContent = ok ? 'Healthy — responding normally' : (checks[key] || 'Error — check logs');
});
} catch(e) {
cards.forEach(c => {
c.querySelector('.health-dot').className = 'health-dot err';
c.querySelector('.text-xs').textContent = e.message || 'Cannot reach server';
});
}
}
/* AUDIT */
async function loadAudit() {
const container = document.getElementById('audit-container');
container.innerHTML = '<div style="padding:40px;text-align:center;"><div class="skeleton" style="height:20px;width:60%;margin:0 auto;"></div></div>';
try {
const data = await apiGet('/super/audit?limit=100');
allAuditEntries = data.entries || [];
document.getElementById('audit-count').textContent = `${allAuditEntries.length} entries`;
renderAudit(allAuditEntries);
} catch(e) { container.innerHTML = `<div style="padding:24px;color:var(--red);">${e.message}</div>`; }
}
function renderAudit(entries) {
const container = document.getElementById('audit-container');
if (!entries.length) { container.innerHTML = '<div class="empty-state"><div class="empty-icon">📜</div><div class="empty-title">No audit entries</div><div class="empty-sub">Actions will be logged here</div></div>'; return; }
container.innerHTML = entries.map(e => `
<div class="audit-entry">
<div class="audit-time">${e.timestamp || '—'}</div>
<div style="flex:1;">
<div><span class="audit-action">${esc(e.action)}</span> by <strong>${esc(e.actor_email || 'system')}</strong> on <em>${esc(e.target_type ? (e.target_type + ':' + (e.target_id||'')) : '—')}</em></div>
${e.details ? `<div class="audit-detail">${esc(e.details)}</div>` : ''}
</div>
</div>
`).join('');
}
function filterAudit() {
const q = document.getElementById('audit-search').value.toLowerCase();
renderAudit(allAuditEntries.filter(e => (e.action||'').toLowerCase().includes(q) || (e.actor_email||'').toLowerCase().includes(q) || (e.target_type||'').toLowerCase().includes(q)));
}
/* PLATFORM API KEYS — one key per provider */
async function loadPlatformApis() {
const wrap = document.getElementById('platform-api-list');
if (!wrap) return;
wrap.innerHTML = '<div class="text-muted text-sm">Loading…</div>';
try {
const data = await apiGet('/super/platform-apis');
const providers = data.providers || [];
const noApi = data.platforms_without_api || [];
let html = providers.map(row => {
const sourceBadge = row.source === 'environment'
? '<span class="badge badge-green" style="margin-left:8px;">env</span>'
: row.source === 'superadmin'
? '<span class="badge badge-blue" style="margin-left:8px;">saved</span>'
: '';
const platformTags = (row.platforms || []).map(p =>
`<span class="badge badge-gray" style="margin:2px 4px 2px 0;">${esc(p.display_name)}</span>`
).join('');
return `<div class="card" style="padding:14px 16px;" data-provider="${esc(row.provider_id)}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:8px;">
<div>
<div style="font-weight:600;">${esc(row.display_name)}${sourceBadge}</div>
<div class="text-xs text-muted" style="margin-top:4px;">${esc(row.description || '')}</div>
</div>
<div class="text-xs text-muted">${row.api_key_hint ? 'Key: ' + esc(row.api_key_hint) : 'No key configured'}</div>
</div>
<div style="margin-top:10px;">${platformTags}</div>
<div class="form-group" style="margin-top:12px;margin-bottom:0;">
<label class="form-label">API key</label>
<input type="password" class="form-input pa-key" placeholder="${row.api_key_hint ? 'Leave blank to keep current key' : 'Paste API key once for all platforms above'}" autocomplete="off">
</div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
<button type="button" class="btn btn-primary btn-sm" onclick="saveProviderApi('${esc(row.provider_id)}')">Save</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="disableProviderApi('${esc(row.provider_id)}')">Disable</button>
<button type="button" class="btn btn-danger btn-sm" onclick="deleteProviderApi('${esc(row.provider_id)}')">Remove</button>
</div>
</div>`;
}).join('');
if (noApi.length) {
html += `<div class="card" style="padding:14px 16px;background:var(--surface2);">
<div class="text-xs text-muted" style="margin-bottom:8px;">Built-in only (no paid API)</div>
${noApi.map(p => `<span class="badge badge-gray" style="margin-right:6px;">${esc(p.display_name)}</span>`).join('')}
</div>`;
}
wrap.innerHTML = html;
} catch (e) {
wrap.innerHTML = `<div class="text-muted text-sm">${esc(e.message)}</div>`;
}
}
async function saveProviderApi(providerId) {
const card = document.querySelector(`[data-provider="${providerId}"]`);
if (!card) return;
const apiKey = card.querySelector('.pa-key')?.value?.trim();
if (!apiKey) { toast('API key is required to save', 'error'); return; }
try {
await apiPut(`/super/platform-apis/providers/${providerId}`, {
provider_id: providerId,
api_key: apiKey,
is_enabled: true,
});
toast('Provider API key saved', 'success');
loadPlatformApis();
} catch (e) { if (!e.message.includes('Session expired')) toast(e.message, 'error'); }
}
async function disableProviderApi(providerId) {
try {
await apiPost(`/super/platform-apis/providers/${providerId}/disable`, {});
toast('Disabled — using env/free fallback', 'success');
loadPlatformApis();
} catch (e) { if (!e.message.includes('Session expired')) toast(e.message, 'error'); }
}
async function deleteProviderApi(providerId) {
if (!confirm('Remove saved API key for this provider?')) return;
try {
await apiDelete(`/super/platform-apis/providers/${providerId}`);
toast('API key removed', 'success');
loadPlatformApis();
} catch (e) { if (!e.message.includes('Session expired')) toast(e.message, 'error'); }
}
/* ANNOUNCEMENTS */
document.getElementById('send-ann-btn').onclick = async () => {
const title = document.getElementById('ann-title').value;
const msg = document.getElementById('ann-msg').value;
if (!msg) { toast('Message is required', 'error'); return; }
try {
await apiPost('/super/announce', { message: msg, title: title });
toast('Announcement sent to all authors!', 'success');
document.getElementById('ann-title').value = '';
document.getElementById('ann-msg').value = '';
} catch(e) { toast(e.message, 'error'); }
};
/* UTILS */
function esc(s) { const d = document.createElement('div'); d.textContent = s||''; return d.innerHTML; }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
</script>
<style>
.sa-for-badge { display:inline-block;background:var(--indigo-soft);color:var(--indigo);padding:2px 10px;border-radius:20px;font-size:12px;font-weight:700;margin-left:4px; }
.sa-no-author-msg::before { content:'🎯 Select an author first — go to Author Config → Select Author'; display:block;padding:40px;text-align:center;color:var(--muted);font-size:14px; }
</style>
<script>
/* ?? SuperAdmin Author Config JS ???????????????????????????????????????? */
let saSelectedSlug = '';
let saSelectedName = '';
let saAllAuthors = [];
async function loadAuthorSelect() {
const el = document.getElementById('sa-author-list');
el.innerHTML = '<p style="color:var(--muted);font-size:13px;text-align:center;padding:20px;">Loading authors…</p>';
try {
const data = await apiGet('/super/authors?limit=500');
saAllAuthors = data.clients || [];
const q = (document.getElementById('sa-author-search')?.value || '').trim();
renderSaAuthors(q ? saAllAuthors.filter(a => matchesSaAuthor(a, q)) : saAllAuthors);
} catch(e) {
el.innerHTML = `<p style="color:var(--red);font-size:13px;text-align:center;padding:20px;">${esc(e.message)}</p>`;
showToast('Failed to load authors: ' + e.message, 'error');
}
}
function matchesSaAuthor(a, q) {
const needle = q.toLowerCase();
return (a.email || '').toLowerCase().includes(needle)
|| (a.full_name || '').toLowerCase().includes(needle);
}
function renderSaAuthors(list) {
const el = document.getElementById('sa-author-list');
if (!list.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px;text-align:center;padding:20px;">No authors found.</p>'; return; }
el.innerHTML = list.map(a => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border:1.5px solid ${a.id===saSelectedSlug?'var(--indigo)':'var(--border)'};border-radius:var(--radius-sm);cursor:pointer;background:${a.id===saSelectedSlug?'var(--indigo-soft)':'var(--white)'}" onclick="selectSaAuthor('${esc(a.id)}','${esc(a.full_name||a.email)}')">
<div><div style="font-weight:600;font-size:14px;">${esc(a.full_name || '—')}</div><div style="font-size:12px;color:var(--muted);">${esc(a.email)}</div></div>
<button class="btn btn-secondary btn-sm" style="font-size:12px;">Select</button>
</div>`).join('');
}
function filterSaAuthors(q) {
renderSaAuthors(saAllAuthors.filter(a => matchesSaAuthor(a, q)));
}
function selectSaAuthor(slug, name) {
saSelectedSlug = slug; saSelectedName = name;
document.getElementById('sa-selected-name').textContent = name + ' (' + slug + ')';
document.getElementById('sa-selected-banner').style.display = 'block';
// Update all "for" badges
['sa-abtests-for','sa-webhooks-for','sa-referral-for','sa-wl-for','sa-apikeys-for','sa-email-for','sa-agency-for','sa-sso-for','sa-gdpr-for'].forEach(id => { const el = document.getElementById(id); if(el) el.textContent = name; });
// Show forms
['sa-wl-form','sa-email-form','sa-sso-form'].forEach(id => { const el = document.getElementById(id); if(el) el.style.display='block'; });
// Remove no-author messages
document.querySelectorAll('.sa-no-author-msg').forEach(el => el.style.display='none');
showToast('Now managing: ' + name, 'success');
renderSaAuthors(saAllAuthors);
}
function requireSaAuthor() {
if (!saSelectedSlug) { showToast('Select an author first (Author Config 🎯 Select Author)', 'error'); return false; }
return true;
}
/* ?? Webhooks ?? */
async function loadSaWebhooks() {
if (!requireSaAuthor()) return;
try {
const data = await apiGet(`/admin/${saSelectedSlug}/webhooks`);
const list = document.getElementById('sa-webhooks-list');
list.innerHTML = (data.webhooks||[]).map(w => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:8px;">
<div><div style="font-size:13px;font-weight:600;">${esc(w.url)}</div><div style="font-size:12px;color:var(--muted);">${(w.events||[]).join(', ')}</div></div>
<div style="display:flex;gap:8px;">
<button class="btn btn-secondary btn-sm" onclick="testSaWebhook('${w.id}')">Test</button>
<button class="btn btn-secondary btn-sm" style="color:var(--red);" onclick="deleteSaWebhook('${w.id}')">Remove</button>
</div>
</div>`).join('') || '<p style="text-align:center;padding:24px;color:var(--muted);font-size:13px;">No webhooks configured.</p>';
} catch(e) { showToast(e.message,'error'); }
}
async function saveSaWebhook() {
if (!requireSaAuthor()) return;
try {
await apiPost(`/admin/${saSelectedSlug}/webhooks`, { url: document.getElementById('sa-wh-url').value, events: document.getElementById('sa-wh-events').value.split(',').map(s=>s.trim()) });
showToast('Webhook added ?'); document.getElementById('sa-webhook-form').style.display='none'; loadSaWebhooks();
} catch(e) { showToast(e.message,'error'); }
}
async function testSaWebhook(id) { try { const r = await apiPost(`/admin/${saSelectedSlug}/webhooks/${id}/test`,{}); showToast(r.success?'Test OK ?':'Test failed ?','info'); } catch(e){showToast(e.message,'error');} }
async function deleteSaWebhook(id) { if(!confirm('Remove webhook?')) return; try { await apiDelete(`/admin/${saSelectedSlug}/webhooks/${id}`); showToast('Removed.'); loadSaWebhooks(); } catch(e){showToast(e.message,'error');} }
/* ?? White-Label ?? */
async function loadSaWhitelabel() {
if (!requireSaAuthor()) return;
try {
const d = await apiGet(`/admin/${saSelectedSlug}/white-label`);
document.getElementById('sa-wl-domain').value = d.custom_domain||'';
document.getElementById('sa-wl-branding').checked = !!d.remove_branding;
document.getElementById('sa-wl-css').value = d.custom_widget_css||'';
document.getElementById('sa-wl-form').style.display='block';
document.getElementById('sa-wl-msg').style.display='none';
} catch(e) { /* not set yet */ }
}
async function saveSaWhitelabel() {
if (!requireSaAuthor()) return;
try {
await apiPut(`/admin/${saSelectedSlug}/white-label`, { custom_domain: document.getElementById('sa-wl-domain').value, remove_branding: document.getElementById('sa-wl-branding').checked, custom_widget_css: document.getElementById('sa-wl-css').value });
showToast('White-label settings saved ?');
} catch(e) { showToast(e.message,'error'); }
}
/* ?? Email Integration ?? */
async function saveSaEmail() {
if (!requireSaAuthor()) return;
try {
await apiPut(`/admin/${saSelectedSlug}/integrations/email`, { provider: document.getElementById('sa-ei-provider').value, api_key: document.getElementById('sa-ei-apikey').value, list_id: document.getElementById('sa-ei-listid').value, sync_on_buy_intent: document.getElementById('sa-ei-intent').checked, sync_on_link_click: document.getElementById('sa-ei-click').checked });
showToast('Email integration saved ?');
} catch(e) { showToast(e.message,'error'); }
}
async function testSaEmail() {
if (!requireSaAuthor()) return;
try { const r = await apiPost(`/admin/${saSelectedSlug}/integrations/email/test`,{}); showToast(r.ok?`Connected (HTTP ${r.status_code}) ?`:`Failed (HTTP ${r.status_code})`,'info'); } catch(e){showToast(e.message,'error');}
}
/* ?? SSO ?? */
async function saveSaSSO() {
if (!requireSaAuthor()) return;
try {
await apiPut(`/admin/${saSelectedSlug}/sso/config`, { idp_metadata_url: document.getElementById('sa-sso-idp').value, attribute_email: document.getElementById('sa-sso-attr-email').value, attribute_name: document.getElementById('sa-sso-attr-name').value, auto_provision: document.getElementById('sa-sso-autoprovision').checked });
showToast('SSO config saved ?');
} catch(e) { showToast(e.message,'error'); }
}
/* ?? GDPR ?? */
async function saExportData() {
if (!requireSaAuthor()) return;
const r = await fetch(`/api/admin/${saSelectedSlug}/data/export`, { method:'POST', headers:{Authorization:`Bearer ${auth.getToken()}`} });
const blob = await r.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `export_${saSelectedSlug}_${new Date().toISOString().slice(0,10)}.json`; a.click(); showToast('Export downloaded ?');
}
async function saAnonymise() {
if (!requireSaAuthor() || !confirm('Anonymise all conversations for '+saSelectedName+'?')) return;
try { const r = await apiPost(`/admin/${saSelectedSlug}/data/anonymize`,{}); showToast(r.message,'info'); } catch(e){showToast(e.message,'error');}
}
async function saRequestDeletion() {
if (!requireSaAuthor() || !confirm('Schedule '+saSelectedName+' account for deletion in 30 days?')) return;
try { const r = await apiPost(`/admin/${saSelectedSlug}/data/delete-request`,{}); showToast(r.message,'info'); } catch(e){showToast(e.message,'error');}
}
</script>
</body>
</html>