diff --git a/panel/public/assets/js/reseller.js b/panel/public/assets/js/reseller.js
new file mode 100644
index 0000000..e038804
--- /dev/null
+++ b/panel/public/assets/js/reseller.js
@@ -0,0 +1,329 @@
+/**
+ * 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';
+ 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 = `
+
+ `;
+
+ const res = await Nova.api('accounts', 'list', { params:{ limit:5 }});
+ const accts = res?.data?.accounts || [];
+
+ document.getElementById('r-stats').innerHTML = [
+ { label: 'Total Accounts', val: res?.data?.total || 0, 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 } : {}});
+ if (!res?.success || !res.data.accounts.length) { el.innerHTML = 'No accounts found.
'; return; }
+ el.innerHTML = `| Username | Domain | Package | Disk | Status | Actions |
+ ${res.data.accounts.map(a => `
+ | ${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'
+ ? ``
+ : ``}
+
+
+ |
+
`).join('')}
+
`;
+}
+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 = ``;
+ } 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 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' },
+];
+const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS };
+
+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;
+ renderRNav();
+ window.resellerNav('dashboard');
+});
diff --git a/panel/public/assets/js/user.js b/panel/public/assets/js/user.js
new file mode 100644
index 0000000..a02a687
--- /dev/null
+++ b/panel/public/assets/js/user.js
@@ -0,0 +1,804 @@
+/**
+ * NovaCPX User Panel JS — all pages
+ */
+
+/* ── Auth guard ──────────────────────────────────────────────────────────── */
+let _user = null;
+
+async function initUser() {
+ const res = await Nova.api('auth', 'me');
+ if (!res || !res.success) {
+ document.getElementById('auth-check').innerHTML = renderLogin();
+ document.getElementById('main-layout').style.display = 'none';
+ return false;
+ }
+ _user = res.data;
+ document.getElementById('user-name').textContent = _user.username || 'User';
+ return true;
+}
+
+function renderLogin() {
+ return `
+
+
+

+
User Portal · Port 8880
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+async function doLogin() {
+ const u = document.getElementById('li-user')?.value;
+ const p = document.getElementById('li-pass')?.value;
+ const err = document.getElementById('li-err');
+ const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: u, password: p } });
+ if (res?.success) {
+ if (res.data?.portal_url && !res.data.portal_url.includes(':8880')) {
+ location.href = res.data.portal_url;
+ } else {
+ location.reload();
+ }
+ } else {
+ if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; }
+ }
+}
+window.doLogin = doLogin;
+
+/* ── Pages ───────────────────────────────────────────────────────────────── */
+
+const userPages = {
+ dashboard,
+ domains,
+ email,
+ databases,
+ ftp,
+ ssl,
+ php: phpPage,
+ cron,
+ files,
+ stats: statsPage,
+ backups,
+};
+
+/* ── Dashboard ───────────────────────────────────────────────────────────── */
+async function dashboard(el) {
+ el.innerHTML = `
+
+ ${['Disk','Bandwidth','Emails','Databases'].map(l => `
`).join('')}
+
+
+
+
+ ${[
+ ['ni-domains','Domains','domains'],
+ ['ni-email','Email','email'],
+ ['ni-databases','Databases','databases'],
+ ['ni-ftp','FTP','ftp'],
+ ['ni-ssl','SSL','ssl'],
+ ['ni-php','PHP','php'],
+ ['ni-cron','Cron Jobs','cron'],
+ ['ni-files','File Manager','files'],
+ ].map(([icon,label,page]) => `
+ `).join('')}
+
+
`;
+
+ const res = await Nova.api('stats', 'account');
+ if (res?.success) {
+ const d = res.data;
+ const rings = document.getElementById('dash-rings');
+ rings.innerHTML = [
+ { label: 'Disk', used: d.disk_mb, limit: d.disk_limit, unit: 'MB' },
+ { label: 'Databases', used: d.databases, limit: d.db_limit, unit: '' },
+ { label: 'Email Accts', used: d.emails, limit: d.email_limit, unit: '' },
+ { label: 'FTP Accts', used: d.ftp, limit: d.ftp_limit, unit: '' },
+ ].map(item => {
+ const pct = item.limit > 0 ? Math.min(100, Math.round(item.used / item.limit * 100)) : 0;
+ const r = 26, circ = 2 * Math.PI * r;
+ const dash = circ - (pct / 100) * circ;
+ const color = pct > 85 ? 'var(--red)' : pct > 65 ? 'var(--yellow)' : 'var(--primary)';
+ return `
+
+
${item.label}
+
${item.used}${item.unit} / ${item.limit > 0 ? item.limit + item.unit : '∞'}
+
`;
+ }).join('');
+ }
+}
+
+/* ── Domains ────────────────────────────────────────────────────────────── */
+async function domains(el) {
+ el.innerHTML = `
+ `;
+
+ await loadDomainsList();
+}
+
+async function loadDomainsList() {
+ const el = document.getElementById('domains-list');
+ if (!el) return;
+ const res = await Nova.api('domains', 'list');
+ if (!res?.success) { el.innerHTML = 'No domains
'; return; }
+ const rows = res.data;
+ el.innerHTML = `| Domain | Type | SSL | Actions |
+ ${rows.map(d => `
+ | ${d.domain} |
+ ${Nova.badge(d.type, d.is_primary ? 'primary' : 'default')} |
+ ${d.ssl_enabled ? Nova.badge('SSL','green') : ``} |
+
+ ${!d.is_primary ? `` : ''}
+ |
+
`).join('')}
+
`;
+}
+window.loadDomainsList = loadDomainsList;
+
+window.addDomain = (type) => {
+ const fields = type === 'subdomain'
+ ? ``
+ : ``;
+ Nova.modal(`Add ${type.charAt(0).toUpperCase()+type.slice(1)}`, `
+ ${fields}
`,
+ ``
+ );
+};
+
+window.submitAddDomain = async (type) => {
+ let body = { type };
+ if (type === 'subdomain') body.subdomain = document.getElementById('md-sub')?.value;
+ else body.domain = document.getElementById('md-domain')?.value;
+
+ const action = type === 'subdomain' ? 'add-subdomain' : type === 'alias' ? 'add-alias' : 'add-addon';
+ const res = await Nova.api('domains', action, { method: 'POST', body });
+ if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadDomainsList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+
+window.removeDomain = (id, domain) => {
+ Nova.confirm(`Remove domain ${domain}? This deletes the vhost and DNS zone.`, async () => {
+ const res = await Nova.api('domains', 'remove', { method: 'POST', body: { id } });
+ if (res?.success) { Nova.toast('Domain removed','success'); loadDomainsList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+ }, true);
+};
+
+window.issueSSL = async (domainId, domain) => {
+ Nova.toast(`Issuing Let's Encrypt SSL for ${domain}…`,'info',6000);
+ const res = await Nova.api('ssl', 'issue', { method: 'POST', body: { domain } });
+ if (res?.success) { Nova.toast('SSL issued successfully','success'); loadDomainsList(); }
+ else Nova.toast(res?.message || 'SSL failed — check domain DNS','error',6000);
+};
+window.issueSSL = window.issueSSL;
+
+/* ── Email ──────────────────────────────────────────────────────────────── */
+async function email(el) {
+ el.innerHTML = `
+
+
+ `;
+
+ loadEmailList();
+ loadForwarderList();
+}
+
+async function loadEmailList() {
+ const el = document.getElementById('email-list');
+ if (!el) return;
+ const res = await Nova.api('email', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No email accounts yet.
'; return; }
+ el.innerHTML = `| Email | Quota | Status | Actions |
+ ${res.data.map(a => `
+ | ${a.email} |
+ ${a.quota_mb > 0 ? a.quota_mb + 'MB' : 'Unlimited'} |
+ ${Nova.badge(a.status, a.status === 'active' ? 'green' : 'yellow')} |
+
+ Webmail
+
+
+ |
+
`).join('')}
+
`;
+}
+window.loadEmailList = loadEmailList;
+
+window.addEmailAccount = () => {
+ Nova.modal('Add Email Account', `
+
+
+ `,
+ ``
+ );
+};
+
+window.submitAddEmail = async () => {
+ const res = await Nova.api('email', 'create', { method: 'POST', body: {
+ email: document.getElementById('em-addr')?.value,
+ password: document.getElementById('em-pass')?.value,
+ quota_mb: parseInt(document.getElementById('em-quota')?.value || '0'),
+ }});
+ if (res?.success) { Nova.toast('Email account created','success'); document.querySelector('.modal-overlay')?.remove(); loadEmailList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+
+window.changeEmailPass = (id) => {
+ Nova.modal('Change Email Password', ``,
+ ``);
+};
+window.submitEmailPass = async (id) => {
+ const res = await Nova.api('email', 'change-password', { method: 'POST', body: { id, password: document.getElementById('ep-pass')?.value }});
+ if (res?.success) { Nova.toast('Password updated','success'); document.querySelector('.modal-overlay')?.remove(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+
+window.deleteEmail = (id, addr) => {
+ Nova.confirm(`Delete ${addr}?`, async () => {
+ const res = await Nova.api('email', 'delete', { method: 'POST', body: { id }});
+ if (res?.success) { Nova.toast('Email deleted','success'); loadEmailList(); }
+ }, true);
+};
+
+window.openWebmail = (email) => {
+ Nova.api('webmail', 'url').then(res => {
+ if (res?.success) window.open(res.data.url, '_blank');
+ });
+};
+
+async function loadForwarderList() {
+ const el = document.getElementById('forwarder-list');
+ if (!el) return;
+ const res = await Nova.api('email', 'forwarders');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No forwarders yet.
'; return; }
+ el.innerHTML = `| From | To | |
+ ${res.data.map(f => `| ${f.source} | ${f.destination} |
+ |
`).join('')}
+
`;
+}
+
+window.addForwarder = () => {
+ Nova.modal('Add Forwarder', `
+
+ `,
+ ``);
+};
+window.submitFwd = async () => {
+ const res = await Nova.api('email', 'add-forwarder', { method: 'POST', body: { source: document.getElementById('fw-from')?.value, destination: document.getElementById('fw-to')?.value }});
+ if (res?.success) { Nova.toast('Forwarder added','success'); document.querySelector('.modal-overlay')?.remove(); loadForwarderList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+window.deleteFwd = async (id) => {
+ const res = await Nova.api('email', 'delete-forwarder', { method: 'POST', body: { id }});
+ if (res?.success) { Nova.toast('Deleted','success'); loadForwarderList(); }
+};
+
+/* ── Databases ──────────────────────────────────────────────────────────── */
+async function databases(el) {
+ el.innerHTML = `
+ `;
+ loadDBList();
+}
+
+async function loadDBList() {
+ const el = document.getElementById('db-list');
+ if (!el) return;
+ const res = await Nova.api('databases', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No databases yet.
'; return; }
+ el.innerHTML = `| Database | User | Type | Size | Actions |
+ ${res.data.map(d => `
+ | ${d.db_name} |
+ ${d.db_user} |
+ ${Nova.badge(d.db_type,'default')} |
+ ${d.size || '—'} |
+
+
+
+ |
+
`).join('')}
+
`;
+}
+window.loadDBList = loadDBList;
+
+window.addDB = (type) => {
+ Nova.modal(`Create ${type.toUpperCase()} Database`, `
+
+
+ `,
+ ``);
+};
+window.submitAddDB = async (type) => {
+ const res = await Nova.api('databases', 'create', { method:'POST', body: { db_type: type, db_name: document.getElementById('dbn-name')?.value, db_user: document.getElementById('dbn-user')?.value, db_pass: document.getElementById('dbn-pass')?.value }});
+ if (res?.success) { Nova.toast('Database created','success'); document.querySelector('.modal-overlay')?.remove(); loadDBList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+window.changeDBPass = (id) => {
+ Nova.modal('Change DB Password', ``,
+ ``);
+};
+window.submitDBPass = async (id) => {
+ const res = await Nova.api('databases', 'change-password', { method:'POST', body:{ id, password: document.getElementById('dbp-pass')?.value }});
+ if (res?.success) { Nova.toast('Password updated','success'); document.querySelector('.modal-overlay')?.remove(); }
+ else Nova.toast(res?.message,'error');
+};
+window.dropDB = (id, name) => {
+ Nova.confirm(`Drop database ${name}? All data will be permanently deleted.`, async () => {
+ const res = await Nova.api('databases', 'drop', { method:'POST', body:{ id }});
+ if (res?.success) { Nova.toast('Database dropped','success'); loadDBList(); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+};
+
+/* ── FTP ────────────────────────────────────────────────────────────────── */
+async function ftp(el) {
+ el.innerHTML = `
+ `;
+ loadFTPList();
+}
+
+async function loadFTPList() {
+ const el = document.getElementById('ftp-list');
+ if (!el) return;
+ const res = await Nova.api('ftp', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No FTP accounts yet.
'; return; }
+ el.innerHTML = `| Username | Directory | Quota | Actions |
+ ${res.data.map(f => `
+ | ${f.username} |
+ ${f.home_dir} |
+ ${f.quota_mb > 0 ? f.quota_mb+'MB' : 'Unlimited'} |
+
+
+
+ |
+
`).join('')}
+
`;
+}
+window.loadFTPList = loadFTPList;
+
+window.addFTP = () => {
+ Nova.modal('Add FTP Account', `
+
+
+ `,
+ ``);
+};
+window.submitAddFTP = async () => {
+ const res = await Nova.api('ftp', 'create', { method:'POST', body:{ username: document.getElementById('ftp-user')?.value, password: document.getElementById('ftp-pass')?.value, home_dir: document.getElementById('ftp-dir')?.value || null }});
+ if (res?.success) { Nova.toast('FTP account created','success'); document.querySelector('.modal-overlay')?.remove(); loadFTPList(); }
+ else Nova.toast(res?.message||'Failed','error');
+};
+window.changeFTPPass = (id) => {
+ Nova.modal('Change FTP Password', ``,
+ ``);
+};
+window.deleteFTP = (id, user) => {
+ Nova.confirm(`Delete FTP account ${user}?`, async () => {
+ const res = await Nova.api('ftp', 'delete', { method:'POST', body:{id}});
+ if (res?.success) { Nova.toast('Deleted','success'); loadFTPList(); }
+ }, true);
+};
+
+/* ── SSL ────────────────────────────────────────────────────────────────── */
+async function ssl(el) {
+ el.innerHTML = `
+ `;
+ loadSSLList();
+}
+
+async function loadSSLList() {
+ const el = document.getElementById('ssl-list');
+ if (!el) return;
+ const res = await Nova.api('ssl', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No SSL certificates yet.
'; return; }
+ el.innerHTML = `| Domain | Type | Expires | Status | Actions |
+ ${res.data.map(c => {
+ const days = c.days_remaining;
+ const status = !days ? 'unknown' : days < 7 ? 'critical' : days < 30 ? 'warning' : 'ok';
+ const badge = days !== null ? `${days}d` : c.status;
+ const badgeType = status === 'critical' ? 'red' : status === 'warning' ? 'yellow' : 'green';
+ return `
+ | ${c.domain} |
+ ${Nova.badge(c.type,'default')} |
+ ${c.expires_at || '—'} |
+ ${Nova.badge(badge, badgeType)} |
+
+
+
+ |
+
`;
+ }).join('')}
+
`;
+}
+window.loadSSLList = loadSSLList;
+
+window.issueNewSSL = () => {
+ Nova.api('domains','list').then(res => {
+ const opts = (res?.data || []).map(d => ``).join('');
+ Nova.modal("Issue Let's Encrypt SSL", `
+
+ `,
+ ``);
+ });
+};
+window.submitIssueSSL = async () => {
+ const domain = document.getElementById('ssl-dom')?.value;
+ Nova.toast(`Issuing SSL for ${domain}…`, 'info', 8000);
+ document.querySelector('.modal-overlay')?.remove();
+ const res = await Nova.api('ssl', 'issue', { method:'POST', body:{ domain, email: document.getElementById('ssl-email')?.value }});
+ if (res?.success) { Nova.toast('SSL issued!','success'); loadSSLList(); }
+ else Nova.toast(res?.message || 'SSL issue failed','error',8000);
+};
+window.renewCert = async (id) => {
+ Nova.toast('Renewing…','info');
+ const res = await Nova.api('ssl', 'renew', { method:'POST', body:{cert_id:id}});
+ if (res?.success) { Nova.toast('Renewed','success'); loadSSLList(); }
+ else Nova.toast(res?.message,'error');
+};
+window.deleteCert = (id, domain) => {
+ Nova.confirm(`Remove SSL cert for ${domain}?`, async () => {
+ const res = await Nova.api('ssl', 'delete', { method:'POST', body:{cert_id:id}});
+ if (res?.success) { Nova.toast('Removed','success'); loadSSLList(); }
+ }, true);
+};
+
+/* ── PHP Manager ────────────────────────────────────────────────────────── */
+async function phpPage(el) {
+ el.innerHTML = `
+ `;
+
+ const [versRes, cfgRes] = await Promise.all([
+ Nova.api('php', 'versions'),
+ Nova.api('php', 'config'),
+ ]);
+
+ if (versRes?.success) {
+ document.getElementById('php-versions').innerHTML = versRes.data.map(v => `
+
+
+ PHP ${v.version}
+ ${v.is_default ? Nova.badge('default','primary') : ''}
+ ${!v.installed ? Nova.badge('not installed','muted') : ''}
+
+ ${v.installed ? `
` : ''}
+
`).join('');
+ }
+
+ if (cfgRes?.success) {
+ const c = cfgRes.data;
+ document.getElementById('php-settings').innerHTML = `
+
+
+
+
+ `;
+ }
+}
+
+window.switchPHP = async (ver) => {
+ const res = await Nova.api('php', 'switch-version', { method:'POST', body:{ version: ver }});
+ if (res?.success) { Nova.toast(`Switched to PHP ${ver}`,'success'); phpPage(document.getElementById('page-content')); }
+ else Nova.toast(res?.message,'error');
+};
+window.savePHPSettings = async () => {
+ const res = await Nova.api('php', 'update-config', { method:'POST', body:{
+ memory_limit: document.getElementById('php-mem')?.value,
+ max_execution_time: document.getElementById('php-exec')?.value,
+ upload_max_filesize: document.getElementById('php-upload')?.value,
+ post_max_size: document.getElementById('php-post')?.value,
+ }});
+ if (res?.success) Nova.toast('PHP settings saved','success');
+ else Nova.toast(res?.message,'error');
+};
+
+/* ── Cron Jobs ──────────────────────────────────────────────────────────── */
+async function cron(el) {
+ el.innerHTML = `
+ `;
+ loadCronList();
+}
+
+async function loadCronList() {
+ const el = document.getElementById('cron-list');
+ if (!el) return;
+ const res = await Nova.api('cron', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No cron jobs yet.
'; return; }
+ el.innerHTML = ``;
+}
+window.loadCronList = loadCronList;
+
+window.addCron = () => {
+ Nova.modal('Add Cron Job', `
+
+
+ ${['minute','hour','day','month','weekday'].map(f => `
`).join('')}
+
+ * = every | */5 = every 5 | 0 = midnight/Jan/Mon
`,
+ ``);
+};
+window.submitCron = async () => {
+ const res = await Nova.api('cron', 'create', { method:'POST', body:{
+ command: document.getElementById('cr-cmd')?.value,
+ minute: document.getElementById('cr-minute')?.value || '*',
+ hour: document.getElementById('cr-hour')?.value || '*',
+ day: document.getElementById('cr-day')?.value || '*',
+ month: document.getElementById('cr-month')?.value || '*',
+ weekday: document.getElementById('cr-weekday')?.value|| '*',
+ }});
+ if (res?.success) { Nova.toast('Cron job added','success'); document.querySelector('.modal-overlay')?.remove(); loadCronList(); }
+ else Nova.toast(res?.message,'error');
+};
+window.toggleCron = async (id) => {
+ await Nova.api('cron', 'toggle', { method:'POST', body:{id}});
+ loadCronList();
+};
+window.deleteCron = (id) => {
+ Nova.confirm('Delete this cron job?', async () => {
+ const res = await Nova.api('cron', 'delete', { method:'POST', body:{id}});
+ if (res?.success) { Nova.toast('Deleted','success'); loadCronList(); }
+ }, true);
+};
+
+/* ── File Manager ───────────────────────────────────────────────────────── */
+let _fmPath = '/public_html';
+
+async function files(el) {
+ el.innerHTML = `
+
+
+
+ ${_fmPath}
+
+
+
+ `;
+
+ loadFMList(_fmPath);
+}
+
+async function loadFMList(path) {
+ _fmPath = path;
+ const pathEl = document.getElementById('fm-path');
+ if (pathEl) pathEl.textContent = path;
+ const el = document.getElementById('fm-list');
+ if (!el) return;
+ const res = await Nova.api('files', 'list', { params: { path }});
+ if (!res?.success) { el.innerHTML = `${res?.message || 'Error loading directory'}
`; return; }
+
+ const parentPath = path.includes('/') ? path.replace(/\/[^/]+$/, '') || '/' : '/';
+ el.innerHTML = `| Name | Size | Perms | Modified | Actions |
+ ${path !== '/' && path !== '/public_html' ? `| ← .. |
` : ''}
+ ${res.data.items.map(f => `
+ |
+ ${f.type === 'dir'
+ ? `📁 ${f.name}`
+ : `📄 ${f.name}`}
+ |
+ ${f.size || '—'} |
+ ${f.perms} |
+ ${f.modified} |
+
+ ${f.type === 'file' ? `` : ''}
+
+
+
+ |
+
`).join('')}
+
`;
+}
+window.fmNav = (p) => loadFMList(p);
+
+window.fmEdit = async (path, name) => {
+ const res = await Nova.api('files', 'read', { params: { path }});
+ if (!res?.success) { Nova.toast(res?.message || 'Cannot read file','error'); return; }
+ const edEl = document.getElementById('fm-editor');
+ edEl.style.display = 'block';
+ edEl.innerHTML = `
+
+
`;
+};
+window.fmSave = async (path) => {
+ const content = document.getElementById('fm-code')?.value || '';
+ const res = await Nova.api('files', 'write', { method:'POST', body:{ path, content }});
+ if (res?.success) Nova.toast('Saved','success');
+ else Nova.toast(res?.message || 'Save failed','error');
+};
+window.fmDelete = (path, name) => {
+ Nova.confirm(`Delete ${name}?`, async () => {
+ const res = await Nova.api('files', 'delete', { method:'POST', body:{ path }});
+ if (res?.success) { Nova.toast('Deleted','success'); loadFMList(_fmPath); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+};
+window.fmMkdir = () => {
+ Nova.modal('New Folder', ``,
+ ``);
+};
+window.fmRename = (path, name) => {
+ const dir = path.replace(/\/[^/]+$/, '');
+ Nova.modal('Rename', ``,
+ ``);
+};
+window.fmChmod = (path, current) => {
+ Nova.modal('Change Permissions', ``,
+ ``);
+};
+window.fmUpload = () => {
+ Nova.modal('Upload File', `
+ `,
+ ``);
+};
+window.submitFMUpload = async () => {
+ const fileInput = document.getElementById('fm-upfile');
+ if (!fileInput?.files[0]) return;
+ const fd = new FormData();
+ fd.append('file', fileInput.files[0]);
+ fd.append('path', _fmPath);
+ const res = await fetch(`/api/files/upload?path=${encodeURIComponent(_fmPath)}`, { method:'POST', credentials:'include', body: fd }).then(r => r.json());
+ if (res?.success) { Nova.toast('Uploaded','success'); document.querySelector('.modal-overlay')?.remove(); loadFMList(_fmPath); }
+ else Nova.toast(res?.message || 'Upload failed','error');
+};
+
+/* ── Stats ──────────────────────────────────────────────────────────────── */
+async function statsPage(el) {
+ el.innerHTML = `
+ `;
+
+ const res = await Nova.api('stats', 'account');
+ if (!res?.success) return;
+ const d = res.data;
+ document.getElementById('stats-grid').innerHTML = [
+ { label: 'Disk Used', val: d.disk_mb + ' MB', limit: d.disk_limit > 0 ? `/ ${d.disk_limit} MB` : '', pct: d.disk_limit > 0 ? Math.min(100,(d.disk_mb/d.disk_limit*100)) : 0 },
+ { label: 'Databases', val: d.databases, limit: d.db_limit > 0 ? `/ ${d.db_limit}` : '', pct: d.db_limit > 0 ? Math.min(100,d.databases/d.db_limit*100) : 0 },
+ { label: 'Email Accounts', val: d.emails, limit: d.email_limit > 0 ? `/ ${d.email_limit}` : '', pct: d.email_limit > 0 ? Math.min(100,d.emails/d.email_limit*100) : 0 },
+ { label: 'FTP Accounts', val: d.ftp, limit: d.ftp_limit > 0 ? `/ ${d.ftp_limit}` : '', pct: d.ftp_limit > 0 ? Math.min(100,d.ftp/d.ftp_limit*100) : 0 },
+ { label: 'Domains', val: d.domains, limit: '', pct: 0 },
+ { label: 'Inodes', val: d.inodes.toLocaleString(), limit: '', pct: 0 },
+ ].map(item => `
+
${item.label}
+
${item.val} ${item.limit}
+ ${item.pct > 0 ? `
${Nova.progressBar(Math.round(item.pct))}
` : ''}
+
`).join('');
+}
+
+/* ── Backups ────────────────────────────────────────────────────────────── */
+async function backups(el) {
+ el.innerHTML = `
+ `;
+
+ const res = await Nova.api('system', 'audit-log', { params:{ limit:5 }});
+ document.getElementById('backup-list').innerHTML = `
+
+
Backup management is being configured by your hosting provider.
+
Contact support to request a manual backup.
+
`;
+}
+window.createBackup = () => Nova.toast('Backup request submitted — you will be notified when ready.','info');
+
+/* ── Navigation ─────────────────────────────────────────────────────────── */
+const navItems = [
+ { id: 'dashboard', label: 'Dashboard', icon: 'ni-dashboard' },
+ { id: 'domains', label: 'Domains', icon: 'ni-domains' },
+ { id: 'email', label: 'Email', icon: 'ni-email' },
+ { id: 'databases', label: 'Databases', icon: 'ni-databases' },
+ { id: 'ftp', label: 'FTP', icon: 'ni-ftp' },
+ { id: 'ssl', label: 'SSL / TLS', icon: 'ni-ssl' },
+ { id: 'php', label: 'PHP', icon: 'ni-php' },
+ { id: 'cron', label: 'Cron Jobs', icon: 'ni-cron' },
+ { id: 'files', label: 'File Manager', icon: 'ni-files' },
+ { id: 'stats', label: 'Statistics', icon: 'ni-stats' },
+ { id: 'backups', label: 'Backups', icon: 'ni-backups' },
+];
+
+let _activePage = 'dashboard';
+
+function renderNav() {
+ const nav = document.getElementById('sidebar-nav');
+ if (!nav) return;
+ nav.innerHTML = navItems.map(n => `
+
+
+ ${n.label}
+ `).join('');
+}
+
+window.userNav = (page) => {
+ _activePage = page;
+ renderNav();
+ const content = document.getElementById('page-content');
+ if (!content) return;
+ content.innerHTML = 'Loading…
';
+ if (userPages[page]) userPages[page](content);
+};
+
+/* ── Boot ────────────────────────────────────────────────────────────────── */
+document.addEventListener('DOMContentLoaded', async () => {
+ const ok = await initUser();
+ if (!ok) return;
+ renderNav();
+ window.userNav('dashboard');
+});
diff --git a/panel/public/reseller/index.php b/panel/public/reseller/index.php
index 707f682..79e31fd 100644
--- a/panel/public/reseller/index.php
+++ b/panel/public/reseller/index.php
@@ -12,7 +12,7 @@
-