# Conflicts:
#	db/migrations/002_features_14_17.sql
This commit is contained in:
2026-06-08 01:22:31 +00:00
13 changed files with 1181 additions and 107 deletions
+23 -1
View File
@@ -14,6 +14,7 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
<body>
<div class="panel-layout" id="app" style="display:none">
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
@@ -97,6 +98,14 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
FTP Server
</a>
<a href="#" class="sidebar-link" data-page="nginx-proxy">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
Nginx Proxy
</a>
<a href="#" class="sidebar-link" data-page="wordpress">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
WordPress
</a>
</div>
<div class="sidebar-section">
@@ -113,6 +122,14 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
Audit Log
</a>
<a href="#" class="sidebar-link" data-page="twofa">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1" fill="currentColor"/></svg>
2FA Manager
</a>
<a href="#" class="sidebar-link" data-page="sessions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
Sessions
</a>
</div>
<div class="sidebar-section">
@@ -125,6 +142,10 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Backups
</a>
<a href="#" class="sidebar-link" data-page="cloudflare">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"/></svg>
Cloudflare
</a>
<a href="#" class="sidebar-link" data-page="settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
@@ -149,7 +170,8 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
<!-- Main Content -->
<div class="main-content">
<header class="topbar">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<div class="topbar-title" id="page-title">Dashboard</div>
<div class="topbar-actions">
<span id="server-ip" class="text-muted text-sm"></span>
+81
View File
@@ -300,3 +300,84 @@ code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em;
.text-muted { color: var(--text-muted); } .text-sm { font-size: .82rem; }
.text-right { text-align: right; } .font-bold { font-weight: 700; }
.w-full { width: 100%; } .hidden { display: none; }
/* ── #26 Mobile Responsive Additions ────────────────────────────────────────── */
#sidebar-toggle { display: none; }
.sidebar-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.5); z-index: 99;
}
.sidebar-overlay.open { display: block; }
/* page-header layout */
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.5rem; flex-wrap: wrap; gap: .75rem;
}
.page-title { font-size: 1.2rem; font-weight: 700; }
.page-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
/* panel utility */
.panel {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); margin-bottom: 1.5rem;
}
.panel-header {
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: .5rem;
}
.panel-title { font-size: .95rem; font-weight: 600; }
.panel-body { padding: 1.25rem; }
/* table alias */
.table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
.table td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg3); }
/* btn variants */
.btn-success { background: var(--green); color: #fff; }
.btn-success:hover { background: #0da271; }
.btn-warning { background: var(--yellow); color: #000; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: var(--red); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-secondary { background: var(--bg3); border: 1px solid var(--border); color: var(--text); }
.btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
.btn-xs { padding: .2rem .55rem; font-size: .75rem; border-radius: 6px; }
/* badge alias */
.badge-muted { background: rgba(148,163,184,.15); color: #94a3b8; }
@media (max-width: 768px) {
#sidebar-toggle { display: flex; }
.sidebar {
transform: translateX(-100%);
transition: transform .25s ease;
z-index: 200;
}
.sidebar.open { transform: translateX(0); }
.main-content { margin-left: 0; }
.page-header { flex-direction: column; align-items: flex-start; }
.page-actions { width: 100%; }
.stats-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.modal { max-width: calc(100vw - 2rem); margin: 1rem; }
.panel-header { flex-direction: column; align-items: flex-start; }
.topbar { padding: .65rem 1rem; }
/* hide non-essential table columns on mobile */
.table th:nth-child(n+4),
.table td:nth-child(n+4) { display: none; }
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
}
+872 -42
View File
@@ -4,19 +4,49 @@
(async () => {
// ── Auth guard ─────────────────────────────────────────────────────────────
// Inline login handler on port 8882
let _loginCredentials = null;
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('l-btn');
const err = document.getElementById('login-err');
btn.disabled = true; btn.textContent = 'Signing in…'; err.style.display = 'none';
const res = await Nova.api('auth', 'login', {
method: 'POST',
body: { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }
});
btn.disabled = true; err.style.display = 'none';
// Step 2: TOTP code entry
const totpInput = document.getElementById('l-totp');
if (totpInput && _loginCredentials) {
btn.textContent = 'Verifying…';
const res = await Nova.api('auth', 'login', {
method: 'POST',
body: { ..._loginCredentials, totp_code: totpInput.value.trim() }
});
if (res?.success && res.data?.user?.role === 'admin') {
location.reload();
} else {
err.textContent = res?.message || 'Invalid 2FA code';
err.style.display = '';
btn.disabled = false; btn.textContent = 'Verify';
}
return;
}
// Step 1: username + password
btn.textContent = 'Signing in…';
const creds = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
const res = await Nova.api('auth', 'login', { method: 'POST', body: creds });
if (res?.success && res.data?.user?.role === 'admin') {
location.reload();
} else if (res?.totp_required) {
// Show TOTP step
_loginCredentials = creds;
document.getElementById('l-user').closest('.form-group').style.display = 'none';
document.getElementById('l-pass').closest('.form-group').style.display = 'none';
const totpGroup = document.createElement('div');
totpGroup.className = 'form-group';
totpGroup.innerHTML = '<label>2FA Code</label><input id="l-totp" type="text" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="6-digit code" autofocus>';
loginForm.insertBefore(totpGroup, btn.parentNode || btn);
btn.textContent = 'Verify'; btn.disabled = false;
} else {
err.textContent = res?.message || 'Invalid credentials or insufficient role';
err.style.display = '';
@@ -57,14 +87,20 @@
'mysql-manager': mysqlManager,
'mail-server': mailServer,
'ftp-server': ftpServer,
'nginx-proxy': nginxProxy,
sessions,
wordpress,
'ssl-manager': sslManager,
firewall,
'audit-log': auditLog,
twofa,
updates,
backups,
cloudflare,
settings,
};
window._novaPages = pages;
Nova.initNav(pages);
await Nova.loadPage('dashboard', pages);
checkUpdates();
@@ -1301,43 +1337,15 @@ ${ips.length ? `
</div>`;
}
// ── Backups ────────────────────────────────────────────────────────────────
async function backups() {
const res = await Nova.api('accounts','list',{params:{limit:1000}});
const accts = res?.data?.accounts || [];
return `
<div class="card">
<div class="card-header">
<span class="card-title">Backup Manager</span>
<button class="btn btn-primary btn-sm" onclick="adminBackupAll()">Backup All Accounts</button>
</div>
<div style="padding:1.25rem">
<div style="margin-bottom:1.5rem;padding:1rem;background:var(--bg3);border-radius:8px;display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
<div class="form-group"><label class="form-label">Backup Storage</label>
<select class="form-control">
<option>Local (/var/backups/novacpx)</option>
<option>rclone (configured)</option>
<option>S3 (configure in settings)</option>
</select>
</div>
<div class="form-group"><label class="form-label">Retention (days)</label>
<input type="number" class="form-control" value="7">
</div>
</div>
<table class="table"><thead><tr><th>Account</th><th>Domain</th><th>Actions</th></tr></thead><tbody>
${accts.slice(0,20).map(a => `<tr>
<td>${a.username}</td>
<td>${a.domain}</td>
<td style="display:flex;gap:.25rem">
<button class="btn btn-xs btn-primary" onclick="adminBackupAccount(${a.id},'${a.username}')">Backup Now</button>
</td>
</tr>`).join('')}
</tbody></table>
</div>
</div>`;
}
window.adminBackupAll = () => Nova.toast('Full backup queued — this may take several minutes.','info',6000);
window.adminBackupAccount = (id, user) => Nova.toast(`Backup queued for ${user}`,'info');
// ── Backups — delegates to backupsFull() defined in additions ─────────────
async function backups() { return backupsFull(); }
// ── Stubs for new pages — implementations in additions block below ─────────
async function wordpress() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
async function cloudflare() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
async function twofa() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
async function nginxProxy() { return `<p class="text-muted">Loading...</p>`; }
async function sessions() { return `<p class="text-muted">Loading...</p>`; }
// ── Global action helpers ──────────────────────────────────────────────────
window.adminPage = (page) => Nova.loadPage(page, pages);
@@ -1406,3 +1414,825 @@ ${ips.length ? `
if (badge && total > 0) { badge.textContent = total; badge.style.display = ''; }
}
})();
// ── ADDITIONS: appended by features #14-17 ────────────────────────────────
// ── WordPress Manager (#14) ────────────────────────────────────────────────
async function wordpress() {
const [acctRes, wpRes] = await Promise.all([
Nova.api('accounts','list',{params:{limit:500}}),
Nova.api('wordpress','list'),
]);
const accts = acctRes?.data?.accounts || [];
const installs = wpRes?.data?.installs || [];
window._adminAcctsWP = accts;
return `
<div class="page-header mb-3">
<h2 class="page-title">WordPress Manager</h2>
<button class="btn btn-primary" onclick="wpInstallModal()">+ Install WordPress</button>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">WordPress Installs</span>
<span class="text-muted text-sm ml-2">${installs.length} install${installs.length!==1?'s':''}</span>
<button class="btn btn-ghost btn-sm ml-auto" onclick="adminPage('wordpress')">&#x21bb; Refresh</button>
</div>
${installs.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>Domain</th><th>Path</th><th>Account</th><th>Version</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
${installs.map(w => `<tr>
<td><strong>${Nova.escHtml(w.domain)}</strong></td>
<td><code>${Nova.escHtml(w.path||'/')}</code></td>
<td>${Nova.escHtml(w.username||'—')}</td>
<td>${w.wp_version ? `<code>${Nova.escHtml(w.wp_version)}</code>` : '—'}</td>
<td>${Nova.badge(w.status||'active', w.status==='active'?'green':w.status==='updating'?'yellow':'red')}</td>
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
<button class="btn btn-xs" onclick="wpInfo(${w.id},'${Nova.escHtml(w.domain)}')">Info</button>
<button class="btn btn-xs btn-primary" onclick="wpUpdate(${w.id},'core')">Update Core</button>
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'plugins')">Plugins</button>
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'themes')">Themes</button>
${!w.staging_of ? `<button class="btn btn-xs" onclick="wpCloneStaging(${w.id},'${Nova.escHtml(w.domain)}')">Clone Staging</button>` : `<span class="badge badge-yellow">staging</span>`}
<button class="btn btn-xs btn-danger" onclick="wpDelete(${w.id},'${Nova.escHtml(w.domain)}')">Delete</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="empty" style="padding:2rem">No WordPress installs yet. Click "Install WordPress" to get started.</div>`}
</div>`;
}
window.wpInstallModal = () => {
const accts = window._adminAcctsWP || [];
const opts = accts.map(a => `<option value="${a.id}">${a.username}${a.domain}</option>`).join('');
Nova.modal('Install WordPress', `
<div class="form-group"><label>Account</label><select id="wp-acct" class="form-control">${opts}</select></div>
<div class="form-group"><label>Domain</label><input id="wp-domain" class="form-control" placeholder="example.com"></div>
<div class="form-group"><label>Path (leave / for root)</label><input id="wp-path" class="form-control" value="/"></div>
<div class="form-group"><label>Site Title</label><input id="wp-title" class="form-control" placeholder="My WordPress Site"></div>
<div class="form-group"><label>WP Admin Username</label><input id="wp-admin" class="form-control" value="admin"></div>
<div class="form-group"><label>WP Admin Password</label><input id="wp-adminpass" type="password" class="form-control"></div>
<div class="form-group"><label>WP Admin Email</label><input id="wp-email" type="email" class="form-control"></div>
<p class="text-muted text-sm">wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.</p>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" id="wp-install-btn" onclick="wpSubmitInstall()">Install</button>`);
};
window.wpSubmitInstall = async () => {
const btn = document.getElementById('wp-install-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; }
Nova.toast('Installing WordPress — this may take 1-2 minutes…', 'info', 90000);
const res = await Nova.api('wordpress','install',{method:'POST',body:{
account_id: +document.getElementById('wp-acct')?.value,
domain: document.getElementById('wp-domain')?.value,
path: document.getElementById('wp-path')?.value || '/',
site_title: document.getElementById('wp-title')?.value,
admin_user: document.getElementById('wp-admin')?.value,
admin_pass: document.getElementById('wp-adminpass')?.value,
admin_email:document.getElementById('wp-email')?.value,
}});
document.querySelector('.modal-overlay')?.remove();
if (res?.success) { Nova.toast('WordPress installed!','success'); adminPage('wordpress'); }
else Nova.toast(res?.message || 'Install failed','error');
};
window.wpUpdate = async (id, type) => {
const action = type === 'core' ? 'update-core' : type === 'plugins' ? 'update-plugins' : 'update-themes';
Nova.toast(`Updating ${type}`,'info',15000);
const r = await Nova.api('wordpress', action, {method:'POST',body:{install_id:id}});
Nova.toast(r?.message || (r?.success ? 'Updated' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('wordpress');
};
window.wpInfo = async (id, domain) => {
Nova.toast('Loading info…','info',5000);
const r = await Nova.api('wordpress','info',{params:{install_id:id}});
if (!r?.success) { Nova.toast(r?.message,'error'); return; }
const d = r.data || {};
const plugins = (d.plugins||[]).map(p => `<tr><td>${Nova.escHtml(p.name)}</td><td>${Nova.escHtml(p.version||'')}</td><td>${Nova.badge(p.status||'inactive',p.status==='active'?'green':'muted')}</td></tr>`).join('');
const themes = (d.themes||[]).map(t => `<tr><td>${Nova.escHtml(t.name)}</td><td>${Nova.escHtml(t.version||'')}</td><td>${Nova.badge(t.status||'inactive',t.status==='active'?'green':'muted')}</td></tr>`).join('');
Nova.modal(`WordPress: ${domain}`,`
<div class="grid-2 mb-2" style="gap:.75rem">
<div><p class="text-muted text-sm">Core Version</p><p class="font-bold">${Nova.escHtml(d.version||'')}</p></div>
<div><p class="text-muted text-sm">Site URL</p><p>${Nova.escHtml(d.siteurl||'')}</p></div>
</div>
<h4 class="mb-1">Plugins (${(d.plugins||[]).length})</h4>
${plugins ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Plugin</th><th>Version</th><th>Status</th></tr></thead><tbody>${plugins}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}
<h4 class="mb-1 mt-2">Themes (${(d.themes||[]).length})</h4>
${themes ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Theme</th><th>Version</th><th>Status</th></tr></thead><tbody>${themes}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}`);
};
window.wpCloneStaging = (id, domain) => {
Nova.confirm(`Clone ${domain} to a staging environment? This copies all files and the database.`, async () => {
Nova.toast('Cloning to staging…','info',30000);
const r = await Nova.api('wordpress','clone-staging',{method:'POST',body:{install_id:id}});
Nova.toast(r?.message || (r?.success ? 'Staging created' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('wordpress');
});
};
window.wpDelete = (id, domain) => {
Nova.confirm(`DELETE WordPress install on ${domain}? This removes all files AND drops the database. IRREVERSIBLE.`, async () => {
const r = await Nova.api('wordpress','delete',{method:'POST',body:{install_id:id}});
Nova.toast(r?.message || (r?.success ? 'Deleted' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('wordpress');
}, true);
};
// ── Backup Manager — full implementation (#15) ─────────────────────────────
async function backupsFull() {
const [acctRes, bkRes] = await Promise.all([
Nova.api('accounts','list',{params:{limit:500}}),
Nova.api('backup','list'),
]);
const accts = acctRes?.data?.accounts || [];
const backupList = bkRes?.data?.backups || [];
const diskUsed = bkRes?.data?.disk_used || 0;
window._adminAcctsBK = accts;
return `
<div class="page-header mb-3">
<h2 class="page-title">Backup Manager</h2>
<div style="display:flex;gap:.5rem">
<button class="btn btn-primary" onclick="bkCreateModal()">+ New Backup</button>
<button class="btn btn-ghost btn-sm" onclick="adminPage('backups')">&#x21bb; Refresh</button>
</div>
</div>
<div class="stats-grid mb-3" style="grid-template-columns:repeat(3,1fr)">
<div class="stat-card">
<div class="stat-label">Total Backups</div>
<div class="stat-value stat-blue">${backupList.length}</div>
</div>
<div class="stat-card">
<div class="stat-label">Disk Used</div>
<div class="stat-value">${Nova.bytes(diskUsed)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Accounts</div>
<div class="stat-value">${accts.length}</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<span class="card-title">Backup Schedules</span>
<button class="btn btn-sm" onclick="bkScheduleModal()">Configure Schedule</button>
</div>
<div class="card-body">
<p class="text-muted text-sm">Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem">
${accts.slice(0,8).map(a => `<button class="btn btn-xs" onclick="bkScheduleForAccount(${a.id},'${Nova.escHtml(a.username)}')">${Nova.escHtml(a.username)}</button>`).join('')}
${accts.length>8?`<span class="text-muted text-sm">+${accts.length-8} more</span>`:''}
</div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">All Backups</span></div>
${backupList.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>Account</th><th>Type</th><th>Size</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
${backupList.map(b => `<tr>
<td>${Nova.escHtml(b.username||b.account_id||'—')}</td>
<td>${Nova.badge(b.type,'default')}</td>
<td>${Nova.bytes(b.size||0)}</td>
<td>${Nova.badge(b.status, b.status==='complete'?'green':b.status==='failed'?'red':'yellow')}</td>
<td>${b.remote_path ? Nova.badge('remote','blue') : Nova.badge('local','muted')}</td>
<td class="text-muted text-sm">${Nova.relTime(b.created_at)}</td>
<td style="display:flex;gap:.25rem">
${b.status==='complete'?`<a class="btn btn-xs" href="/api/backup/download?id=${b.id}" target="_blank">Download</a>`:''}
<button class="btn btn-xs btn-warning" onclick="bkRestore(${b.id})">Restore</button>
<button class="btn btn-xs btn-danger" onclick="bkDelete(${b.id})">Del</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="empty" style="padding:2rem">No backups yet.</div>`}
</div>`;
}
window.bkCreateModal = () => {
const accts = window._adminAcctsBK || [];
const opts = accts.map(a => `<option value="${a.id}">${a.username}${a.domain}</option>`).join('');
Nova.modal('Create Backup', `
<div class="form-group"><label>Account</label><select id="bk-acct" class="form-control">${opts}</select></div>
<div class="form-group"><label>Type</label>
<select id="bk-type" class="form-control">
<option value="full">Full (files + database)</option>
<option value="files">Files only</option>
<option value="database">Database only</option>
</select>
</div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="bkSubmitCreate()">Create Backup</button>`);
};
window.bkSubmitCreate = async () => {
const id = +document.getElementById('bk-acct')?.value;
const type = document.getElementById('bk-type')?.value;
document.querySelector('.modal-overlay')?.remove();
Nova.toast('Creating backup…','info',30000);
const r = await Nova.api('backup','create',{method:'POST',body:{account_id:id,type}});
Nova.toast(r?.message||(r?.success?'Backup complete':'Failed'), r?.success?'success':'error');
if (r?.success) adminPage('backups');
};
window.bkRestore = (id) => {
Nova.confirm('Restore this backup? Current files and databases will be overwritten. IRREVERSIBLE.', async () => {
Nova.toast('Restoring…','info',30000);
const r = await Nova.api('backup','restore',{method:'POST',body:{id}});
Nova.toast(r?.message||(r?.success?'Restored':'Failed'), r?.success?'success':'error');
}, true);
};
window.bkDelete = (id) => {
Nova.confirm('Delete this backup?', async () => {
const r = await Nova.api('backup','delete',{method:'POST',body:{id}});
Nova.toast(r?.message||(r?.success?'Deleted':'Failed'), r?.success?'success':'error');
if (r?.success) adminPage('backups');
}, true);
};
window.bkScheduleModal = () => {
const accts = window._adminAcctsBK || [];
const opts = accts.map(a => `<option value="${a.id}">${a.username}</option>`).join('');
Nova.modal('Configure Backup Schedule', `
<div class="form-group"><label>Account</label><select id="bks-acct" class="form-control">${opts}</select></div>
<div class="form-group"><label>Frequency</label>
<select id="bks-freq" class="form-control">
<option value="hourly">Hourly</option>
<option value="daily" selected>Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="form-group"><label>Type</label>
<select id="bks-type" class="form-control">
<option value="full">Full</option>
<option value="files">Files only</option>
<option value="database">Database only</option>
</select>
</div>
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="7"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="bkSaveSchedule()">Save Schedule</button>`);
};
window.bkScheduleForAccount = async (id, user) => {
const r = await Nova.api('backup','get-schedule',{params:{account_id:id}});
const s = r?.data || {};
Nova.modal(`Schedule: ${user}`, `
<div class="form-group"><label>Frequency</label>
<select id="bks-freq" class="form-control">
${['hourly','daily','weekly','monthly'].map(f=>`<option value="${f}"${s.frequency===f?' selected':''}>${f.charAt(0).toUpperCase()+f.slice(1)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>Type</label>
<select id="bks-type" class="form-control">
${['full','files','database'].map(t=>`<option value="${t}"${s.type===t?' selected':''}>${t.charAt(0).toUpperCase()+t.slice(1)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="${s.retain_count||7}"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="bkSaveScheduleFor(${id})">Save</button>`);
};
window.bkSaveSchedule = async () => {
const id = +document.getElementById('bks-acct')?.value;
await bkSaveScheduleFor(id);
};
window.bkSaveScheduleFor = async (id) => {
const r = await Nova.api('backup','schedule',{method:'POST',body:{
account_id: id,
frequency: document.getElementById('bks-freq')?.value,
type: document.getElementById('bks-type')?.value,
retain: +document.getElementById('bks-retain')?.value,
}});
document.querySelector('.modal-overlay')?.remove();
Nova.toast(r?.message||(r?.success?'Schedule saved':'Failed'), r?.success?'success':'error');
};
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
async function cloudflare() {
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
const accts = acctRes?.data?.accounts || [];
window._adminAcctsCF = accts;
return `
<div class="page-header mb-3">
<h2 class="page-title">Cloudflare Integration</h2>
<p class="text-muted text-sm">Manage Cloudflare API credentials and DNS sync per account.</p>
</div>
<div class="card mb-3">
<div class="card-header"><span class="card-title">Account Credentials</span></div>
<div class="card-body">
<p class="text-muted text-sm mb-2">Select an account to configure or view its Cloudflare API key.</p>
<div style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group mb-0">
<label class="form-label text-sm">Account</label>
<select id="cf-acct-sel" class="form-control form-control-sm" onchange="cfLoadAccount(this.value)">
<option value=""> Select Account </option>
${accts.map(a=>`<option value="${a.id}">${a.username}${a.domain}</option>`).join('')}
</select>
</div>
</div>
<div id="cf-acct-panel" style="margin-top:1rem"></div>
</div>
</div>
<div class="card" id="cf-zones-panel" style="display:none">
<div class="card-header">
<span class="card-title">Cloudflare Zones</span>
<button class="btn btn-ghost btn-sm" onclick="cfRefreshZones()">&#x21bb; Refresh Zones</button>
</div>
<div id="cf-zones-body" class="card-body">
<p class="text-muted text-sm">Save credentials first, then click Refresh Zones.</p>
</div>
</div>`;
}
window.cfLoadAccount = async (id) => {
if (!id) { document.getElementById('cf-acct-panel').innerHTML=''; return; }
const r = await Nova.api('cloudflare','get-credentials',{params:{account_id:id}});
const c = r?.data || {};
document.getElementById('cf-acct-panel').innerHTML = `
<div class="grid-2" style="gap:.75rem;max-width:600px">
<div class="form-group"><label class="form-label">API Email</label>
<input id="cf-email" class="form-control" type="email" value="${Nova.escHtml(c.cf_api_email||'')}" placeholder="you@example.com"></div>
<div class="form-group"><label class="form-label">Global API Key</label>
<input id="cf-apikey" class="form-control" type="text" value="${Nova.escHtml(c.cf_api_key||'')}" placeholder="API key from Cloudflare dashboard"></div>
</div>
<div style="display:flex;gap:.5rem;margin-top:.5rem">
<button class="btn btn-sm btn-primary" onclick="cfSaveCredentials(${id})">Save Credentials</button>
<button class="btn btn-sm btn-ghost" onclick="cfTestKey(${id})">Test API Key</button>
</div>
${c.cf_api_key ? `<p class="text-muted text-sm mt-1">Key on file: <code>${Nova.escHtml(c.cf_api_key)}</code></p>` : ''}`;
document.getElementById('cf-zones-panel').style.display = '';
window._cfCurrentAcct = id;
};
window.cfSaveCredentials = async (id) => {
const r = await Nova.api('cloudflare','save-credentials',{method:'POST',body:{
account_id: id,
api_key: document.getElementById('cf-apikey')?.value,
email: document.getElementById('cf-email')?.value,
}});
Nova.toast(r?.message||(r?.success?'Saved':'Failed'), r?.success?'success':'error');
};
window.cfTestKey = async (id) => {
const r = await Nova.api('cloudflare','test-key',{method:'POST',body:{
account_id: id,
api_key: document.getElementById('cf-apikey')?.value,
email: document.getElementById('cf-email')?.value,
}});
Nova.toast(r?.message||(r?.data?.valid?'API key is valid':'Invalid key'), r?.data?.valid?'success':'error');
};
window.cfRefreshZones = async () => {
const id = window._cfCurrentAcct;
if (!id) { Nova.toast('Select an account first','error'); return; }
const r = await Nova.api('cloudflare','list-zones',{params:{account_id:id}});
const zones = r?.data?.zones || r?.data || [];
const body = document.getElementById('cf-zones-body');
if (!body) return;
if (!r?.success) { body.innerHTML=`<p class="text-muted">${Nova.escHtml(r?.message||'Failed to load zones')}</p>`; return; }
if (!zones.length) { body.innerHTML='<p class="text-muted text-sm">No zones found for these credentials.</p>'; return; }
body.innerHTML = `
<table class="table">
<thead><tr><th>Zone</th><th>Status</th><th>Plan</th><th>Actions</th></tr></thead>
<tbody>
${zones.map(z=>`<tr>
<td><strong>${Nova.escHtml(z.name)}</strong><br><code style="font-size:.75rem">${Nova.escHtml(z.id)}</code></td>
<td>${Nova.badge(z.status,z.status==='active'?'green':'yellow')}</td>
<td class="text-muted text-sm">${Nova.escHtml(z.plan?.name||'—')}</td>
<td style="display:flex;gap:.25rem">
<button class="btn btn-xs" onclick="cfViewRecords('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}',${id})">DNS Records</button>
<button class="btn btn-xs btn-primary" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','to',${id})">Push to CF</button>
<button class="btn btn-xs" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','from',${id})">Pull from CF</button>
<button class="btn btn-xs btn-warning" onclick="cfPurge('${Nova.escHtml(z.id)}',${id})">Purge Cache</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
};
window.cfViewRecords = async (zoneId, domain, acctId) => {
const r = await Nova.api('cloudflare','list-records',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
const records = r?.data?.records || r?.data || [];
Nova.modal(`CF DNS: ${domain}`, !records.length ? '<p class="text-muted">No records.</p>' : `
<table class="table" style="font-size:.82rem">
<thead><tr><th>Name</th><th>Type</th><th>Value</th><th>Proxy</th></tr></thead>
<tbody>
${records.map(rec=>`<tr>
<td>${Nova.escHtml(rec.name)}</td>
<td>${Nova.badge(rec.type,'default')}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><code>${Nova.escHtml(rec.content)}</code></td>
<td>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer">
<input type="checkbox" ${rec.proxiable&&rec.proxied?'checked':''} ${!rec.proxiable?'disabled':''}
onchange="cfToggleProxy('${zoneId}','${rec.id}',this.checked,${acctId})">
${rec.proxied?Nova.badge('proxied','orange'):Nova.badge('DNS only','muted')}
</label>
</td>
</tr>`).join('')}
</tbody>
</table>`);
};
window.cfToggleProxy = async (zoneId, recordId, proxied, acctId) => {
const r = await Nova.api('cloudflare','toggle-proxy',{method:'POST',body:{zone_id:zoneId,record_id:recordId,proxied,account_id:acctId}});
Nova.toast(r?.message||(r?.success?'Updated':'Failed'), r?.success?'success':'error');
};
window.cfSync = async (zoneId, domain, dir, acctId) => {
const action = dir==='to' ? 'sync-to-cf' : 'sync-from-cf';
const label = dir==='to' ? 'Pushing to Cloudflare' : 'Pulling from Cloudflare';
Nova.toast(`${label}`,'info',10000);
const r = await Nova.api('cloudflare',action,{method:'POST',body:{zone_id:zoneId,domain,account_id:acctId}});
Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error');
};
window.cfPurge = async (zoneId, acctId) => {
Nova.confirm('Purge all Cloudflare cache for this zone?', async () => {
const r = await Nova.api('cloudflare','purge-cache',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
Nova.toast(r?.message||(r?.success?'Cache purged':'Failed'), r?.success?'success':'error');
});
};
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
async function twofa() {
const res = await Nova.api('accounts','list',{params:{limit:500}});
const users = res?.data?.accounts || [];
return `
<div class="page-header mb-3">
<h2 class="page-title">Two-Factor Authentication</h2>
<p class="text-muted text-sm">View 2FA status for all users. Force-disable for account recovery.</p>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">User 2FA Status</span>
<button class="btn btn-ghost btn-sm" onclick="adminPage('twofa')">&#x21bb; Refresh</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>2FA Status</th><th>Actions</th></tr></thead>
<tbody id="totp-user-rows">
${users.map(u=>`<tr>
<td><strong>${Nova.escHtml(u.username)}</strong></td>
<td class="text-muted text-sm">${Nova.escHtml(u.email||'—')}</td>
<td>${Nova.badge(u.role||'user','default')}</td>
<td id="totp-status-${u.id}">
<span class="text-muted text-sm"></span>
</td>
<td>
<button class="btn btn-xs btn-ghost" onclick="totpCheckStatus(${u.id})">Check</button>
<button class="btn btn-xs btn-warning" onclick="totpAdminDisable(${u.id},'${Nova.escHtml(u.username)}')">Force Disable</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>
</div>`;
}
window.totpCheckStatus = async (userId) => {
const r = await Nova.api('totp','admin-status',{method:'POST',body:{user_id:userId}});
const el = document.getElementById(`totp-status-${userId}`);
if (!el) return;
const enabled = r?.data?.totp_enabled;
el.innerHTML = enabled
? Nova.badge('Enabled','green')
: Nova.badge('Disabled','muted');
};
window.totpAdminDisable = (userId, username) => {
Nova.confirm(`Force-disable 2FA for ${username}? Use only for account recovery when user cannot log in.`, async () => {
const r = await Nova.api('totp','admin-disable',{method:'POST',body:{user_id:userId}});
Nova.toast(r?.message||(r?.success?'2FA disabled':'Failed'), r?.success?'success':'error');
if (r?.success) {
const el = document.getElementById(`totp-status-${userId}`);
if (el) el.innerHTML = Nova.badge('Disabled','muted');
}
}, true);
};
// ── Nginx Proxy Manager ───────────────────────────────────────────────────────
async function nginxProxy() {
const [statusR, hostsR] = await Promise.all([
Nova.api('proxy', 'status'),
Nova.api('proxy', 'hosts'),
]);
const s = statusR?.data || {};
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
const run = s.running;
const inst = s.installed;
return `
<div class="page-header">
<h1 class="page-title">Nginx Proxy Manager</h1>
<div class="page-actions">
${inst ? `
<button class="btn btn-ghost btn-sm" onclick="proxySetupInstructions()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
Setup Guide
</button>
<button class="btn btn-sm btn-secondary" onclick="proxySync()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Sync Accounts
</button>
<button class="btn btn-sm btn-primary" onclick="proxyAddHost()">+ Add Host</button>
` : ''}
</div>
</div>
<div class="stats-grid" style="margin-bottom:1.5rem">
<div class="stat-card">
<div class="stat-label">Nginx Status</div>
<div class="stat-value ${run ? 'stat-green' : 'stat-red'}">${inst ? (run ? 'Running' : 'Stopped') : 'Not Installed'}</div>
<div class="stat-sub">${s.version || (inst ? 'nginx' : 'click Install to set up')}</div>
</div>
<div class="stat-card">
<div class="stat-label">Proxy Hosts</div>
<div class="stat-value">${hosts.length}</div>
<div class="stat-sub">${hosts.filter(h => h.enabled).length} active</div>
</div>
<div class="stat-card">
<div class="stat-label">SSL Enabled</div>
<div class="stat-value">${hosts.filter(h => h.ssl_enabled).length}</div>
<div class="stat-sub">of ${hosts.length} hosts</div>
</div>
</div>
${!inst ? `
<div class="panel" style="text-align:center;padding:3rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48" style="color:var(--text-muted);margin-bottom:1rem"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<h3 style="margin-bottom:0.5rem">Nginx Not Installed</h3>
<p style="color:var(--text-muted);margin-bottom:1.5rem">Install Nginx on this VM to use it as a reverse proxy in front of Apache, or use a separate proxy VM (see Setup Guide).</p>
<div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap">
<button class="btn btn-primary" onclick="proxyInstall()">Install Nginx Locally</button>
<button class="btn btn-secondary" onclick="proxySetupInstructions()">Setup Guide / Remote VM</button>
</div>
</div>
` : `
<div class="panel" style="margin-bottom:1.5rem">
<div class="panel-header">
<h3 class="panel-title">Service Controls</h3>
<div style="display:flex;gap:0.5rem">
<button class="btn btn-sm btn-success" onclick="proxyControl('start')">Start</button>
<button class="btn btn-sm btn-warning" onclick="proxyControl('restart')">Restart</button>
<button class="btn btn-sm btn-danger" onclick="proxyControl('stop')">Stop</button>
<button class="btn btn-sm btn-ghost" onclick="proxyControl('reload')">Reload Config</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Proxy Hosts</h3>
<span class="badge badge-blue">${hosts.length} total</span>
</div>
${hosts.length === 0 ? `
<div style="text-align:center;padding:2rem;color:var(--text-muted)">
No proxy hosts yet. Click <strong>Sync Accounts</strong> to auto-add all hosted domains, or <strong>+ Add Host</strong> to add manually.
</div>
` : `
<div style="overflow-x:auto">
<table class="table">
<thead><tr>
<th>Domain</th>
<th>Upstream</th>
<th>SSL</th>
<th>Status</th>
<th>Actions</th>
</tr></thead>
<tbody>
${hosts.map(h => `
<tr id="proxy-row-${h.id}">
<td><strong>${Nova.escHtml(h.domain)}</strong></td>
<td style="font-family:monospace;font-size:0.8rem">${Nova.escHtml(h.upstream)}</td>
<td>${h.ssl_enabled ? Nova.badge('SSL','green') : Nova.badge('HTTP','muted')}</td>
<td>${h.enabled ? Nova.badge('Active','green') : Nova.badge('Disabled','red')}</td>
<td>
<button class="btn btn-xs btn-ghost" onclick="proxyEditHost(${h.id})">Edit</button>
<button class="btn btn-xs ${h.enabled ? 'btn-warning' : 'btn-success'}" onclick="proxyToggle(${h.id},${h.enabled ? 0 : 1})">${h.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-xs btn-danger" onclick="proxyDeleteHost(${h.id},'${Nova.escHtml(h.domain)}')">Delete</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>
`}
</div>
`}`;
}
window.proxyInstall = async () => {
if (!confirm('Install Nginx on this VM? This will run apt-get install nginx.')) return;
Nova.toast('Installing nginx...', 'info');
const r = await Nova.api('proxy', 'install', { method: 'POST' });
Nova.toast(r?.data?.result || r?.message || 'Done', r?.data?.result === 'installed' ? 'success' : 'info');
Nova.loadPage('nginx-proxy', window._novaPages);
};
window.proxyControl = async (action) => {
const r = await Nova.api('proxy', 'control', { method: 'POST', body: { action } });
Nova.toast(r?.data?.result || r?.message || action + ' done', 'success');
setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 800);
};
window.proxySync = async () => {
const r = await Nova.api('proxy', 'sync', { method: 'POST' });
Nova.toast(`Synced: ${r?.data?.added ?? 0} new hosts added`, 'success');
Nova.loadPage('nginx-proxy', window._novaPages);
};
window.proxyAddHost = () => {
Nova.modal('Add Proxy Host', `
<div class="form-group"><label>Domain</label>
<input id="ph-domain" type="text" placeholder="example.com" class="form-control"></div>
<div class="form-group"><label>Upstream URL</label>
<input id="ph-upstream" type="text" value="http://127.0.0.1:80" class="form-control">
<small class="text-muted">e.g. http://127.0.0.1:80 or http://10.0.0.2:8080</small></div>
<div class="form-group">
<label><input type="checkbox" id="ph-ssl"> Enable SSL</label></div>
<div class="form-group"><label>Notes (optional)</label>
<input id="ph-notes" type="text" class="form-control"></div>
`, async () => {
const domain = document.getElementById('ph-domain')?.value?.trim();
const upstream = document.getElementById('ph-upstream')?.value?.trim();
if (!domain || !upstream) { Nova.toast('Domain and upstream required', 'error'); return; }
const r = await Nova.api('proxy', 'hosts', {
method: 'POST',
body: { domain, upstream, ssl_enabled: document.getElementById('ph-ssl')?.checked ? 1 : 0 }
});
Nova.toast(r?.success ? 'Host added' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
});
};
window.proxyEditHost = async (id) => {
const hostsR = await Nova.api('proxy', 'hosts');
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
const h = hosts.find(x => x.id == id);
if (!h) return;
Nova.modal('Edit Proxy Host', `
<div class="form-group"><label>Domain</label>
<input id="phe-domain" type="text" value="${Nova.escHtml(h.domain)}" class="form-control"></div>
<div class="form-group"><label>Upstream URL</label>
<input id="phe-upstream" type="text" value="${Nova.escHtml(h.upstream)}" class="form-control"></div>
<div class="form-group">
<label><input type="checkbox" id="phe-ssl" ${h.ssl_enabled ? 'checked' : ''}> Enable SSL</label></div>
<div class="form-group"><label>Custom Nginx Config (overrides auto-generated)</label>
<textarea id="phe-custom" rows="6" class="form-control" style="font-family:monospace;font-size:0.78rem">${Nova.escHtml(h.custom_config || '')}</textarea>
<small class="text-muted">Leave blank to use auto-generated config</small></div>
`, async () => {
const r = await Nova.api('proxy', 'host', {
method: 'PUT',
body: { id,
domain: document.getElementById('phe-domain')?.value?.trim(),
upstream: document.getElementById('phe-upstream')?.value?.trim(),
ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0,
custom_config: document.getElementById('phe-custom')?.value?.trim() || null,
}
});
Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
});
};
window.proxyToggle = async (id, enable) => {
const r = await Nova.api('proxy', 'toggle', { method: 'POST', body: { id, enabled: enable } });
Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
};
window.proxyDeleteHost = (id, domain) => {
Nova.confirm(`Delete proxy host for ${domain}?`, async () => {
const r = await Nova.api('proxy', 'host', { method: 'DELETE', body: { id } });
Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
}, true);
};
window.proxySetupInstructions = async () => {
const scriptUrl = '/api/proxy/setup-script';
Nova.modal('Nginx Proxy Setup Guide', `
<div style="max-height:60vh;overflow-y:auto">
<h4 style="margin-bottom:0.75rem">Option A Local (Nginx on this VM)</h4>
<p style="color:var(--text-muted);margin-bottom:1rem">Install Nginx alongside Apache on this VM. Nginx listens on ports 80/443 and forwards to Apache. Best for SSL termination and caching.</p>
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem;line-height:1.8">
<li>Click <strong>Install Nginx Locally</strong> on the main Nginx Proxy page</li>
<li>Move Apache to port 8080: edit <code>/etc/apache2/ports.conf</code> change <code>Listen 80</code> to <code>Listen 8080</code></li>
<li>Update upstream in all proxy hosts to <code>http://127.0.0.1:8080</code></li>
<li>Click <strong>Sync Accounts</strong> to auto-populate proxy hosts from your hosted accounts</li>
<li>Click <strong>Reload Config</strong> to apply changes</li>
</ol>
<h4 style="margin-bottom:0.75rem">Option B Remote Proxy VM (Recommended for production)</h4>
<p style="color:var(--text-muted);margin-bottom:1rem">Run a dedicated Nginx proxy VM in front of this NovaCPX VM. Traffic flows: Internet FortiGate Nginx Proxy VM NovaCPX VM (Apache).</p>
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem;line-height:1.8">
<li>Create a new VM on Proxmox (Ubuntu 22.04, 1 vCPU, 1GB RAM)</li>
<li>Run the setup script below on the new VM as root</li>
<li>Point FortiGate VIPs to the proxy VM IP (ports 80/443)</li>
<li>Set the proxy upstream to this NovaCPX VM IP (<code>http://10.48.200.110:80</code>)</li>
<li>Add proxy hosts for each domain from your NovaCPX admin panel</li>
</ol>
<h4 style="margin-bottom:0.75rem">Automated Setup Script</h4>
<p style="color:var(--text-muted);margin-bottom:0.75rem">Run this on the target VM (local or remote) as root:</p>
<div style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-family:monospace;font-size:0.8rem;margin-bottom:0.75rem">
curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script | bash
</div>
<p style="color:var(--text-muted);font-size:0.85rem">Or download and review before running:</p>
<div style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-family:monospace;font-size:0.8rem">
curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script -o proxy-setup.sh<br>
cat proxy-setup.sh # review<br>
bash proxy-setup.sh
</div>
<h4 style="margin-bottom:0.75rem;margin-top:1.5rem">Integration with VirtualHost Manager</h4>
<p style="color:var(--text-muted);margin-bottom:0.75rem">When proxy mode is active, NovaCPX automatically:</p>
<ul style="color:var(--text-muted);padding-left:1.2rem;line-height:1.8">
<li>Creates a proxy host entry for every new account</li>
<li>Removes the proxy host when an account is terminated</li>
<li>Re-generates Nginx config on every account change</li>
<li>Uses account SSL certs automatically if SSL is enabled on the proxy host</li>
</ul>
</div>
`, null, { cancelLabel: 'Close', showConfirm: false });
};
// ── #29 Session Manager ───────────────────────────────────────────────────────
async function sessions() {
const r = await Nova.api('sessions', 'list');
const rows = r?.data || [];
const fmt = d => new Date(d.replace(' ','T')+'Z').toLocaleString();
const ua = s => {
if (!s) return '—';
const m = s.match(/\(([^)]+)\)/);
return m ? m[1].split(';')[0].slice(0,50) : s.slice(0,50);
};
return `
<div class="page-header">
<h1 class="page-title">Session Manager</h1>
<div class="page-actions">
<button class="btn btn-sm btn-danger" onclick="sessionsRevokeAll()">Revoke All Sessions</button>
</div>
</div>
<div class="stats-grid" style="margin-bottom:1.5rem">
<div class="stat-card"><div class="stat-label">Active Sessions</div><div class="stat-value">${rows.length}</div></div>
<div class="stat-card"><div class="stat-label">Unique Users</div><div class="stat-value">${new Set(rows.map(r=>r.user_id)).size}</div></div>
<div class="stat-card"><div class="stat-label">Unique IPs</div><div class="stat-value">${new Set(rows.map(r=>r.ip_address)).size}</div></div>
</div>
<div class="panel">
<div class="panel-header"><h3 class="panel-title">Active Sessions</h3><span class="badge badge-blue">${rows.length} total</span></div>
${rows.length === 0
? '<div style="padding:2rem;text-align:center;color:var(--text-muted)">No active sessions</div>'
: `<div style="overflow-x:auto"><table class="table"><thead><tr>
<th>User</th><th>Role</th><th>IP</th><th>Browser</th><th>Created</th><th>Expires</th><th>Actions</th>
</tr></thead><tbody>
${rows.map(s=>`<tr>
<td><strong>${Nova.escHtml(s.username)}</strong><br><small class="text-muted">${Nova.escHtml(s.email)}</small></td>
<td>${Nova.badge(s.role, s.role==='admin'?'red':s.role==='reseller'?'yellow':'blue')}</td>
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(s.ip_address)}</td>
<td style="font-size:.8rem;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${Nova.escHtml(s.user_agent||'')}">${Nova.escHtml(ua(s.user_agent||''))}</td>
<td style="font-size:.82rem">${fmt(s.created_at)}</td>
<td style="font-size:.82rem">${fmt(s.expires_at)}</td>
<td>
<button class="btn btn-xs btn-danger" onclick="sessionsRevoke('${s.id}')">Revoke</button>
<button class="btn btn-xs btn-warning" onclick="sessionsRevokeUser(${s.user_id},'${Nova.escHtml(s.username)}')">All for User</button>
</td></tr>`).join('')}
</tbody></table></div>`}
</div>`;
}
window.sessionsRevoke = async (id) => {
const r = await Nova.api('sessions','revoke',{method:'DELETE',body:{session_id:id}});
Nova.toast(r?.success?'Session revoked':'Failed',r?.success?'success':'error');
if (r?.success) Nova.loadPage('sessions',window._novaPages);
};
window.sessionsRevokeUser = (uid,name) => {
Nova.confirm(`Revoke all sessions for ${name}? They will be logged out everywhere.`,async()=>{
const r=await Nova.api('sessions','revoke-user',{method:'DELETE',body:{user_id:uid}});
Nova.toast(r?.success?`${r.data?.revoked??'?'} sessions revoked`:'Failed',r?.success?'success':'error');
if(r?.success) Nova.loadPage('sessions',window._novaPages);
},true);
};
window.sessionsRevokeAll = () => {
Nova.confirm('Revoke ALL sessions? Everyone including you will be logged out.',async()=>{
const r=await Nova.api('sessions','revoke-all',{method:'DELETE',body:{}});
Nova.toast(r?.success?'All sessions revoked — logging out...':'Failed',r?.success?'success':'error');
if(r?.success) setTimeout(()=>location.reload(),1500);
},true);
};
+19
View File
@@ -144,3 +144,22 @@ window.Nova = (() => {
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml };
})();
// #26 Mobile sidebar toggle — shared across all panels
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (!toggle || !sidebar) return;
const open = () => { sidebar.classList.add('open'); overlay?.classList.add('open'); document.body.style.overflow = 'hidden'; };
const close = () => { sidebar.classList.remove('open'); overlay?.classList.remove('open'); document.body.style.overflow = ''; };
toggle.addEventListener('click', () => sidebar.classList.contains('open') ? close() : open());
overlay?.addEventListener('click', close);
// Close when a nav link is clicked on mobile
sidebar.querySelectorAll('.sidebar-link').forEach(link =>
link.addEventListener('click', () => { if (window.innerWidth <= 768) close(); })
);
});
+39
View File
@@ -0,0 +1,39 @@
<?php http_response_code(404); ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 Page Not Found · NovaCPX</title>
<style>
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
background-image:radial-gradient(ellipse at 30% 20%,rgba(99,102,241,.12) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(239,68,68,.07) 0%,transparent 60%)}
.wrap{text-align:center;padding:2rem;max-width:480px}
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,var(--primary),#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
.btn:hover{opacity:.85}
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
.logo svg{width:28px;height:28px}
.logo-text{font-size:1.1rem;font-weight:300}
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
<div class="logo-text">Nova<strong>CPX</strong></div>
</div>
<div class="code">404</div>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<a href="javascript:history.back()" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
Go Back
</a>
</div>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<?php http_response_code(500); ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>500 Server Error · NovaCPX</title>
<style>
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
background-image:radial-gradient(ellipse at 30% 20%,rgba(239,68,68,.1) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(99,102,241,.07) 0%,transparent 60%)}
.wrap{text-align:center;padding:2rem;max-width:480px}
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,#ef4444,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
.btn:hover{opacity:.85}
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
.logo svg{width:28px;height:28px}
.logo-text{font-size:1.1rem;font-weight:300}
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
<div class="logo-text">Nova<strong>CPX</strong></div>
</div>
<div class="code">500</div>
<h1>Internal Server Error</h1>
<p>Something went wrong on our end. The issue has been logged. Please try again in a moment.</p>
<a href="javascript:history.back()" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
Go Back
</a>
</div>
</body>
</html>
+1
View File
@@ -41,6 +41,7 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
<div class="main-content">
<header class="topbar">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<div class="topbar-title" id="page-title">Reseller Dashboard</div>
</header>
<div class="page-content" id="page-content"></div>
+1
View File
@@ -158,6 +158,7 @@ svg.ring circle { transition: stroke-dashoffset .5s; }
<div class="main-content">
<header class="topbar">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<div class="topbar-title" id="page-title">My Hosting</div>
<div class="topbar-actions">
<span id="account-domain" class="text-muted text-sm"></span>