Spaces:
Running
Running
| <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 ; } | |
| @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 & 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 & 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 & 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> | |