/** * NovaCPX Reseller Panel JS */ let _rUser = null; async function initReseller() { const res = await Nova.api('auth', 'me'); if (!res?.success || !['admin','reseller'].includes(res.data?.role)) { document.getElementById('auth-check').innerHTML = renderLogin(); document.getElementById('main-layout').style.display = 'none'; return false; } _rUser = res.data; document.getElementById('user-name').textContent = _rUser.username || 'Reseller'; document.getElementById('auth-check').style.display = 'none'; document.getElementById('main-layout').style.display = ''; return true; } function renderLogin() { return `
Reseller Portal · Port 8881
`; } async function doLogin() { const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }}); if (res?.success) { if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url; else location.reload(); } else { const err = document.getElementById('li-err'); if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; } } } window.doLogin = doLogin; /* ── Pages ─────────────────────────────────────────────────────────────── */ async function rDashboard(el) { el.innerHTML = `
Loading…
Recent Accounts
Loading…
`; const res = await Nova.api('accounts', 'list', { params:{ limit:5 }}); const accts = res?.data || []; document.getElementById('r-stats').innerHTML = [ { label: 'Total Accounts', val: res?.meta?.total || accts.length, icon: 'ni-accounts' }, { label: 'Active', val: accts.filter(a=>a.status==='active').length, icon: 'ni-stats' }, { label: 'Suspended', val: accts.filter(a=>a.status==='suspended').length, icon: 'ni-suspend' }, ].map(s => `
${s.val}
${s.label}
`).join(''); document.getElementById('r-recent').innerHTML = accts.length ? ` ${accts.map(a => ``).join('')}
UsernameDomainPackageStatus
${a.username}${a.domain}${a.package_name||'—'} ${Nova.badge(a.status, a.status==='active'?'green':'yellow')}
` : '
No accounts yet.
'; } async function rAccounts(el) { el.innerHTML = `
Loading…
`; loadRAccounts(); } async function loadRAccounts(search = '') { const el = document.getElementById('r-accounts-list'); if (!el) return; const res = await Nova.api('accounts', 'list', { params: search ? { search } : {}}); const acctRows = res?.data || []; if (!res?.success || !acctRows.length) { el.innerHTML = '
No accounts found.
'; return; } el.innerHTML = ` ${acctRows.map(a => ``).join('')}
UsernameDomainPackageDiskStatusActions
${a.username} ${a.domain} ${a.package_name || '—'} ${a.disk_usage_mb || 0} MB ${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')} ${a.status === 'active' ? `` : ``}
`; } window.loadRAccounts = loadRAccounts; window.rSearchAccounts = (v) => loadRAccounts(v); window.rSuspend = async (id, user) => { Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => { const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }}); if (res?.success) { Nova.toast('Account suspended','success'); loadRAccounts(); } else Nova.toast(res?.message,'error'); }); }; window.rUnsuspend = async (id, user) => { const res = await Nova.api('accounts', 'unsuspend', { method:'POST', body:{ account_id: id }}); if (res?.success) { Nova.toast('Account unsuspended','success'); loadRAccounts(); } else Nova.toast(res?.message,'error'); }; window.rTerminate = (id, user) => { Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, databases, DNS, and email. THIS CANNOT BE UNDONE.`, async () => { const res = await Nova.api('accounts', 'terminate', { method:'POST', body:{ account_id: id }}); if (res?.success) { Nova.toast('Account terminated','success'); loadRAccounts(); } else Nova.toast(res?.message,'error'); }, true); }; window.rChangePass = (id, user) => { Nova.modal(`Change Password — ${user}`, `
`, ``); }; async function rCreateAccount(el) { el.innerHTML = `
`; Nova.api('packages', 'list').then(res => { const sel = document.getElementById('ca-pkg'); if (sel && res?.success) { sel.innerHTML = res.data.map(p => ``).join(''); } }); } window.submitCreateAccount = async () => { const btn = document.querySelector('#ca-result'); if (btn) btn.textContent = ''; const res = await Nova.api('accounts', 'create', { method:'POST', body:{ username: document.getElementById('ca-user')?.value, password: document.getElementById('ca-pass')?.value, email: document.getElementById('ca-email')?.value, domain: document.getElementById('ca-domain')?.value, package_id: document.getElementById('ca-pkg')?.value, }}); if (res?.success) { Nova.toast('Account created successfully!','success'); if (btn) btn.innerHTML = `
Account created! View accounts →
`; } else { Nova.toast(res?.message || 'Failed to create account','error'); if (btn) btn.innerHTML = `
${res?.message || 'Error'}
`; } }; async function rPackages(el) { el.innerHTML = `
Loading…
`; const res = await Nova.api('packages', 'list'); const plist = document.getElementById('pkg-list'); if (!res?.success || !res.data.length) { plist.innerHTML = '
No packages yet.
'; return; } plist.innerHTML = ` ${res.data.map(p => ``).join('')}
NameDiskBWDBsEmailsDomainsPriceActions
${p.name} ${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'} ${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'} ${p.databases || '∞'} ${p.email_accounts || '∞'} ${p.addon_domains || '∞'} ${p.price ? '$'+p.price : 'Free'}
`; } window.rAddPackage = () => showPackageModal(); window.rEditPackage = async (id) => { const res = await Nova.api('packages', 'get', { params:{ id }}); if (res?.success) showPackageModal(res.data); }; function showPackageModal(pkg = null) { const p = pkg || {}; Nova.modal(pkg ? 'Edit Package' : 'Add Package', `
`, ``); } window.submitPackage = async (id) => { const body = { name:document.getElementById('pk-name')?.value, disk_mb:parseInt(document.getElementById('pk-disk')?.value), bandwidth_mb:parseInt(document.getElementById('pk-bw')?.value), databases:parseInt(document.getElementById('pk-db')?.value), email_accounts:parseInt(document.getElementById('pk-email')?.value), addon_domains:parseInt(document.getElementById('pk-adom')?.value), subdomains:parseInt(document.getElementById('pk-sub')?.value), ftp_accounts:parseInt(document.getElementById('pk-ftp')?.value), price:parseFloat(document.getElementById('pk-price')?.value) }; const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body}); if (res?.success) { Nova.toast(id ? 'Package updated' : 'Package created','success'); document.querySelector('.modal-overlay')?.remove(); rPackages(document.getElementById('page-content')); } else Nova.toast(res?.message,'error'); }; window.rDeletePackage = (id, name) => { Nova.confirm(`Delete package "${name}"? Cannot delete if accounts are using it.`, async () => { const res = await Nova.api('packages','delete',{method:'POST',body:{id}}); if (res?.success) { Nova.toast('Deleted','success'); rPackages(document.getElementById('page-content')); } else Nova.toast(res?.message,'error'); }, true); }; async function rDNS(el) { el.innerHTML = `
Loading…
`; const res = await Nova.api('dns', 'zones'); const list = document.getElementById('r-dns-list'); if (!res?.success || !res.data.length) { list.innerHTML = '
No DNS zones.
'; return; } list.innerHTML = ` ${res.data.map(z => ``).join('')}
DomainAccountRecordsActions
${z.domain} ${z.username||'—'} ${z.record_count||0}
`; } window.rViewZone = async (zoneId, domain) => { const res = await Nova.api('dns', 'records', { params:{ zone_id: zoneId }}); if (!res?.success) { Nova.toast('Failed to load records','error'); return; } const rows = res.data.map(r => ` ${r.name}${Nova.badge(r.type,'default')}${r.value}${r.ttl} `).join(''); Nova.modal(`DNS Records — ${domain}`, ` ${rows}
NameTypeValueTTL
`); }; window.rAddRecord = (zoneId, domain) => { Nova.modal('Add DNS Record', `
`, ``); }; window.rDeleteRecord = async (id, zoneId, domain) => { Nova.confirm('Delete this DNS record?', async () => { const res = await Nova.api('dns', 'delete-record', { method:'POST', body:{id, zone_id: zoneId }}); if (res?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); rViewZone(zoneId, domain); } else Nova.toast(res?.message,'error'); }, true); }; /* ── Nav ────────────────────────────────────────────────────────────────── */ const rNavItems = [ { id:'dashboard', label:'Dashboard', icon:'ni-dashboard' }, { id:'accounts', label:'Accounts', icon:'ni-accounts' }, { id:'createAccount', label:'New Account', icon:'ni-add' }, { id:'packages', label:'Packages', icon:'ni-packages' }, { id:'dns', label:'DNS Zones', icon:'ni-dns' }, { id:'docker', label:'Docker', icon:'ni-docker' }, { id:'whitelabel', label:'White Label', icon:'ni-settings' }, ]; const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel }; let _rActivePage = 'dashboard'; function renderRNav() { const nav = document.getElementById('sidebar-nav'); if (!nav) return; nav.innerHTML = rNavItems.map(n => ` ${n.label} `).join(''); } window.resellerNav = (page) => { _rActivePage = page; renderRNav(); const content = document.getElementById('page-content'); if (!content) return; content.innerHTML = '
Loading…
'; if (rPages[page]) rPages[page](content); }; document.addEventListener('DOMContentLoaded', async () => { const ok = await initReseller(); if (!ok) return; document.getElementById('logout-btn')?.addEventListener('click', async e => { e.preventDefault(); await Nova.api('auth', 'logout', { method: 'POST' }); location.href = '/'; }); renderRNav(); window.resellerNav('dashboard'); }); /* ── Docker (Reseller #33) ────────────────────────────────────────────────── */ async function rDocker(el) { el.innerHTML = '
Loading…
'; const [stRes, acctRes] = await Promise.all([ Nova.api('docker', 'stacks'), Nova.api('accounts', 'list', { params: { limit: 200 } }), ]); const stacks = stRes?.data?.stacks || []; const accts = acctRes?.data || []; el.innerHTML = `

Manage Docker containers and quotas for your customers. Contact the server admin to change your own Docker allocation.

Loading…
`; window._rDockerAccts = accts; window._rDockerTab = window._rDockerTab || 'containers'; window.rDockerTab = async (tab) => { window._rDockerTab = tab; document.querySelectorAll('[onclick^="rDockerTab"]').forEach(b => { const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1]; b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost'); }); await rDockerLoadTab(tab); }; await rDockerLoadTab(window._rDockerTab); } window._rDockerTab = 'containers'; async function rDockerLoadTab(tab) { const tc = document.getElementById('rdocker-content'); if (!tc) return; tc.innerHTML = '
Loading…
'; if (tab === 'containers') { const r = await Nova.api('docker', 'containers'); const rows = r?.data?.containers || []; tc.innerHTML = rows.length === 0 ? '
No containers for your accounts
' : `
${rows.map(c=>``).join('')}
NameImageStatusAccountActions
${Nova.escHtml(c.name)} ${Nova.escHtml(c.image)} ${Nova.badge(c.status,c.status==='running'?'green':'red')} ${c.account_id||'—'} ${c.status==='running' ? `` : ``}
`; } else if (tab === 'quotas') { const accts = window._rDockerAccts || []; tc.innerHTML = accts.length === 0 ? '
No accounts
' : `

Set Docker limits for each of your customers.

${accts.map(u=>``).join('')}
UsernameMax ContainersMax MemoryMax CPUsActions
${Nova.escHtml(u.username)} 2512 MB1.0
`; } else if (tab === 'catalog') { const r = await Nova.api('docker', 'catalog'); const catalog = r?.data?.catalog || {}; const accts = window._rDockerAccts || []; tc.innerHTML = `

Pre-install app stacks for your customers.

${Object.entries(catalog).map(([key,app])=>`
${Nova.escHtml(app.icon)}
${Nova.escHtml(app.name)}
${Nova.escHtml(app.description)}
`).join('')}
`; } } window.rDockerAct = async (cid, action) => { const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } }); Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error'); if (r?.success) rDockerLoadTab('containers'); }; window.rDockerLogs = async (cid, name) => { const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } }); Nova.modal(`Logs: ${name}`, `
${Nova.escHtml(r?.data?.logs||'')}
`); }; window.rDockerQuotaModal = (userId, username) => { const ov = Nova.modal(`Docker Quota: ${username}`, `
`, ` ` ); window.rDockerQuotaSubmit = async (uid) => { ov.remove(); const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{ user_id: uid, max_containers: parseInt(document.getElementById('rdq-cnt').value)||2, max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512, max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0, }}); Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error'); }; }; window.rDockerLaunchModal = async (appKey, appName) => { const catRes = await Nova.api('docker', 'catalog'); const app = catRes?.data?.catalog?.[appKey]; if (!app) return; const accts = window._rDockerAccts || []; const acctOpts = accts.map(a=>``).join(''); const paramFields = (app.params||[]).map(p=>`
`).join(''); const ov = Nova.modal(`Deploy ${appName}`, `
${paramFields}`, ` ` ); window.rDockerLaunchSubmit = async (key) => { const acctId = parseInt(document.getElementById('rl-acct').value)||0; if (!acctId) { Nova.toast('Select an account','error'); return; } const params = {}; (app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; }); ov.remove(); Nova.toast('Deploying…', 'info', 10000); const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }}); Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error'); if (r?.success) rDockerLoadTab('containers'); }; }; // ── White Label / Branding (#18) ──────────────────────────────────────────── async function rWhiteLabel(el) { el.innerHTML = '
Loading…
'; const res = await Nova.api('branding', 'get'); const b = res?.data || {}; el.innerHTML = `
Panel Identity
${b.logo_url ? `
` : ''}
${b.logo_url ? `` : ''} PNG/SVG/JPG · max 512 KB
Colors
Support
`; // Sync color pickers ↔ hex inputs ↔ preview ['primary','accent'].forEach(k => { const picker = document.getElementById('wl-'+k); const hex = document.getElementById('wl-'+k+'-hex'); const sync = () => { if (picker) hex.value = picker.value; rWlUpdatePreview(); }; const syncBack = () => { if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; rWlUpdatePreview(); } }; picker?.addEventListener('input', sync); hex?.addEventListener('input', syncBack); }); } function rWlUpdatePreview() { const p = document.getElementById('wl-primary-hex')?.value || '#6366f1'; const a = document.getElementById('wl-accent-hex')?.value || '#0ea5e9'; const el = document.getElementById('wl-color-preview'); if (el) el.style.background = `linear-gradient(135deg,${p},${a})`; // Live-preview CSS vars const style = document.getElementById('reseller-branding') || (() => { const s = document.createElement('style'); s.id = 'reseller-branding'; document.head.appendChild(s); return s; })(); style.textContent = `:root { --primary: ${p}; --primary-dark: ${p}; --accent: ${a}; }`; } window.rWlUploadLogo = async () => { const file = document.getElementById('wl-logo-file')?.files?.[0]; if (!file) return; if (file.size > 512 * 1024) { Nova.toast('Logo must be under 512 KB', 'error'); return; } const fd = new FormData(); fd.append('logo', file); Nova.toast('Uploading…', 'info', 5000); try { const res = await fetch('/api/branding/upload-logo', { method: 'POST', credentials: 'include', body: fd }); const data = await res.json(); Nova.toast(data?.success ? 'Logo uploaded' : (data?.message || 'Upload failed'), data?.success ? 'success' : 'error'); if (data?.success) rWhiteLabel(document.getElementById('page-content')); } catch (e) { Nova.toast('Upload failed', 'error'); } }; window.rWlDeleteLogo = async () => { const r = await Nova.api('branding', 'delete-logo', { method: 'POST' }); Nova.toast(r?.success ? 'Logo removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) rWhiteLabel(document.getElementById('page-content')); }; window.rWlSave = async () => { const body = { panel_name: document.getElementById('wl-name')?.value?.trim() || 'NovaCPX', primary_color: document.getElementById('wl-primary-hex')?.value || '#6366f1', accent_color: document.getElementById('wl-accent-hex')?.value || '#0ea5e9', support_email: document.getElementById('wl-email')?.value?.trim() || '', support_url: document.getElementById('wl-url')?.value?.trim() || '', hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0, custom_css: document.getElementById('wl-css')?.value || '', }; const r = await Nova.api('branding', 'save', { method: 'POST', body }); Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'), r?.success ? 'success' : 'error'); };