/**
* 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 `
`;
}
async function doLogin() {
Nova.loading('Signing in…');
const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }});
Nova.loadingDone();
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 = `
`;
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 => ``).join('');
document.getElementById('r-recent').innerHTML = accts.length
? `| Username | Domain | Package | Status |
${accts.map(a => `
| ${a.username} | ${a.domain} | ${a.package_name||'—'} |
${Nova.badge(a.status, a.status==='active'?'green':'yellow')} |
`).join('')}
`
: 'No accounts yet.
';
}
async function rAccounts(el) {
el.innerHTML = `
`;
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 = `| Username | Domain | Package | Disk | Status | Actions |
${acctRows.map(a => `
| ${Nova.escHtml(a.username)} |
${Nova.escHtml(a.domain)} |
${a.package_name ? Nova.escHtml(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'
? ``
: ``}
|
`).join('')}
`;
}
window.loadRAccounts = loadRAccounts;
window.rSearchAccounts = (v) => loadRAccounts(v);
window.rLoginAs = async (userId, username) => {
Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => {
Nova.loading(`Switching to ${username}…`);
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
Nova.loadingDone();
if (res?.success) {
window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
} else {
Nova.toast(res?.message || 'Impersonation failed', 'error');
}
});
};
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 = '';
Nova.loading('Creating hosting account…');
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,
}});
Nova.loadingDone();
if (res?.success) {
Nova.toast('Account created successfully!','success');
if (btn) btn.innerHTML = ``;
} else {
Nova.toast(res?.message || 'Failed to create account','error');
if (btn) btn.innerHTML = `${res?.message || 'Error'}
`;
}
};
async function rPackages(el) {
el.innerHTML = `
`;
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 = `| Name | Disk | BW | DBs | Emails | Domains | Price | Actions |
${res.data.map(p => `
| ${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'} |
|
`).join('')}
`;
}
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 = `
`;
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 = `| Domain | Account | Records | Actions |
${res.data.map(z => `
| ${z.domain} |
${z.username||'—'} |
${z.record_count||0} |
|
`).join('')}
`;
}
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}`,
`
`);
};
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 rNavGroups = [
{ label: 'Overview', items: [
{ id: 'dashboard', label: 'Dashboard',
svg: '' },
]},
{ label: 'Accounts', items: [
{ id: 'accounts', label: 'All Accounts',
svg: '' },
{ id: 'createAccount', label: 'New Account',
svg: '' },
{ id: 'packages', label: 'Packages',
svg: '' },
]},
{ label: 'DNS', items: [
{ id: 'dns', label: 'DNS Zones',
svg: '' },
]},
{ label: 'Tools', items: [
{ id: 'docker', label: 'Docker',
svg: '' },
{ id: 'whitelabel', label: 'White Label',
svg: '' },
]},
];
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 = rNavGroups.map(g => `
`).join('');
nav.querySelectorAll('[data-page]').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
if (window.innerWidth <= 768) {
document.getElementById('sidebar')?.classList.remove('open');
document.getElementById('sidebar-overlay')?.classList.remove('open');
document.body.style.overflow = '';
}
resellerNav(link.dataset.page);
});
});
}
window.resellerNav = (page) => {
_rActivePage = page;
renderRNav();
const allItems = rNavGroups.flatMap(g => g.items);
const item = allItems.find(n => n.id === page);
const titleEl = document.getElementById('page-title');
if (titleEl && item) titleEl.textContent = item.label;
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.
`;
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
'
: `| Name | Image | Status | Account | Actions |
${rows.map(c=>`
| ${Nova.escHtml(c.name)} |
${Nova.escHtml(c.image)} |
${Nova.badge(c.status,c.status==='running'?'green':'red')} |
${c.account_id||'—'} |
${c.status==='running'
? ``
: ``}
|
`).join('')}
`;
} else if (tab === 'quotas') {
const accts = window._rDockerAccts || [];
tc.innerHTML = accts.length === 0
? 'No accounts
'
: `Set Docker limits for each of your customers.
| Username | Max Containers | Max Memory | Max CPUs | Actions |
${accts.map(u=>`
| ${Nova.escHtml(u.username)} |
2 | 512 MB | 1.0 |
|
`).join('')}
`;
} 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) => {
Nova.loading(`${action.charAt(0).toUpperCase()+action.slice(1)}ing container…`);
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
Nova.loadingDone();
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.loading(`Deploying ${appName}… this may take a minute`);
const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }});
Nova.loadingDone();
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 = `
`;
// 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 || '',
};
Nova.loading('Saving branding…');
const r = await Nova.api('branding', 'save', { method: 'POST', body });
Nova.loadingDone();
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
r?.success ? 'success' : 'error');
};