From 6dd2e3a08d53bf943e7394a2327b30e2a88662f6 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sat, 20 Jun 2026 05:40:00 +0000 Subject: [PATCH] fix: add all server-only assets and panel files missing from repo Previously missing from git (rsync --delete was wiping them on every deploy): - assets/css/nova.css - assets/js/nova.js, features.js, reseller.js, user.js - assets/img/*.svg (favicon, icons, logo, mark) - index.php, _branding.php, errors/404.php, errors/500.php - reseller/index.php, user/index.php Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ --- panel/_branding.php | 80 ++ panel/assets/css/nova.css | 387 +++++++++ panel/assets/img/nova-favicon.svg | 9 + panel/assets/img/nova-icons.svg | 329 ++++++++ panel/assets/img/nova-logo.svg | 26 + panel/assets/img/nova-mark.svg | 15 + panel/assets/js/admin.js | 2 +- panel/assets/js/features.js | 133 +++ panel/assets/js/nova.js | 234 ++++++ panel/assets/js/reseller.js | 700 ++++++++++++++++ panel/assets/js/user.js | 1276 +++++++++++++++++++++++++++++ panel/errors/404.php | 39 + panel/errors/500.php | 39 + panel/index.php | 130 +++ panel/reseller/index.php | 94 +++ panel/user/index.php | 155 ++++ 16 files changed, 3647 insertions(+), 1 deletion(-) create mode 100644 panel/_branding.php create mode 100644 panel/assets/css/nova.css create mode 100644 panel/assets/img/nova-favicon.svg create mode 100644 panel/assets/img/nova-icons.svg create mode 100644 panel/assets/img/nova-logo.svg create mode 100644 panel/assets/img/nova-mark.svg create mode 100644 panel/assets/js/features.js create mode 100644 panel/assets/js/nova.js create mode 100644 panel/assets/js/reseller.js create mode 100644 panel/assets/js/user.js create mode 100644 panel/errors/404.php create mode 100644 panel/errors/500.php create mode 100644 panel/index.php create mode 100644 panel/reseller/index.php create mode 100644 panel/user/index.php diff --git a/panel/_branding.php b/panel/_branding.php new file mode 100644 index 0000000..0521840 --- /dev/null +++ b/panel/_branding.php @@ -0,0 +1,80 @@ + before JS loads. + * Reads session cookie → looks up user's reseller → returns branding row. + */ +function novacpx_get_branding(): array { + static $cache = null; + if ($cache !== null) return $cache; + $cfg = @parse_ini_file('/etc/novacpx/config.ini', true); + if (!$cfg) return $cache = []; + try { + $dbPath = $cfg['database']['path'] ?? '/var/lib/novacpx/panel.db'; + $pdo = new PDO( + "sqlite:{$dbPath}", null, null, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] + ); + $token = $_COOKIE['ncpx_session'] ?? ''; + if (!$token || strlen($token) < 32) return $cache = []; + + $stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now') LIMIT 1"); + $stmt->execute([substr($token, 0, 128)]); + $uid = (int)($stmt->fetchColumn() ?: 0); + if (!$uid) return $cache = []; + + $stmt = $pdo->prepare("SELECT role, reseller_id FROM users WHERE id = ? LIMIT 1"); + $stmt->execute([$uid]); + $u = $stmt->fetch(); + if (!$u) return $cache = []; + + $resellerId = ($u['role'] === 'reseller') ? $uid : (int)($u['reseller_id'] ?? 0); + if (!$resellerId) return $cache = []; + + $stmt = $pdo->prepare("SELECT * FROM reseller_branding WHERE user_id = ? LIMIT 1"); + $stmt->execute([$resellerId]); + $row = $stmt->fetch(); + return $cache = ($row ?: []); + } catch (Throwable $e) { + return $cache = []; + } +} + +function novacpx_branding_head(): void { + $b = novacpx_get_branding(); + if (!$b) return; + $pc = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['primary_color'] ?? '') ? $b['primary_color'] : null; + $ac = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['accent_color'] ?? '') ? $b['accent_color'] : null; + $css = $b['custom_css'] ?? ''; + echo ' tags + echo preg_replace('/<\s*\/\s*style/i', '', $css) . "\n"; + echo '' . "\n"; + if ($b['favicon_url'] ?? '') { + $fav = htmlspecialchars($b['favicon_url']); + echo "\n"; + } +} + +function novacpx_panel_name(string $default): string { + $b = novacpx_get_branding(); + return htmlspecialchars($b['panel_name'] ?? $default); +} + +function novacpx_logo_html(string $default_svg): string { + $b = novacpx_get_branding(); + if (!empty($b['logo_url'])) { + $url = htmlspecialchars($b['logo_url']); + $name = htmlspecialchars($b['panel_name'] ?? 'Panel'); + return "\"$name\""; + } + return $default_svg; +} + +function novacpx_powered_by(): bool { + $b = novacpx_get_branding(); + return empty($b['hide_powered_by']); +} diff --git a/panel/assets/css/nova.css b/panel/assets/css/nova.css new file mode 100644 index 0000000..3bceb03 --- /dev/null +++ b/panel/assets/css/nova.css @@ -0,0 +1,387 @@ +/* NovaCPX Design System */ +:root { + --bg: #0d0f17; + --bg2: #131520; + --bg3: #1a1d2e; + --border: #252840; + --text: #e2e4f0; + --text-muted: #7c7f9a; + --primary: #6366f1; + --primary-h: #4f52e8; + --sky: #0ea5e9; + --green: #10b981; + --yellow: #f59e0b; + --red: #ef4444; + --radius: 10px; + --shadow: 0 4px 24px rgba(0,0,0,.4); + --font: 'Inter', system-ui, -apple-system, sans-serif; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 15px; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + line-height: 1.6; + min-height: 100vh; +} + +/* ── Login Page ─────────────────────────────────────────────────────────────── */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,.15) 0%, transparent 60%), + radial-gradient(ellipse at 80% 80%, rgba(14,165,233,.1) 0%, transparent 60%), + var(--bg); +} + +.login-wrap { width: 100%; max-width: 420px; padding: 1.5rem; } + +.login-brand { + display: flex; align-items: center; gap: .75rem; + justify-content: center; margin-bottom: 2rem; +} +.logo-icon { width: 42px; height: 42px; } +.logo-text { font-size: 1.8rem; font-weight: 300; letter-spacing: -.5px; } +.logo-text strong { font-weight: 700; background: linear-gradient(135deg, #6366f1, #0ea5e9); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } + +.login-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + box-shadow: var(--shadow); +} +.login-card h1 { font-size: 1.4rem; margin-bottom: .25rem; } +.login-sub { color: var(--text-muted); font-size: .875rem; margin-bottom: 1.5rem; } + +.login-footer { + text-align: center; margin-top: 1.25rem; + font-size: .8rem; color: var(--text-muted); +} +.login-footer a { color: var(--primary); text-decoration: none; } + +/* ── Forms ──────────────────────────────────────────────────────────────────── */ +.form-group { margin-bottom: 1rem; } +.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-muted); } + +input[type="text"], input[type="password"], input[type="email"], +input[type="number"], input[type="url"], input[type="search"], +input[type="date"], input[type="time"], input[type="tel"], +input:not([type]), .form-control, select, textarea { + width: 100%; padding: .65rem .9rem; + background: var(--bg3); border: 1px solid var(--border); + border-radius: var(--radius); color: var(--text); + font-family: var(--font); font-size: .9rem; + transition: border-color .15s; + outline: none; + box-sizing: border-box; +} +input:focus, select:focus, textarea:focus, .form-control:focus { border-color: var(--primary); } +input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacity(.5); } + +.input-with-icon { position: relative; } +.input-with-icon input { padding-right: 2.5rem; } +.eye-toggle { + position: absolute; right: .75rem; top: 50%; transform: translateY(-50%); + background: none; border: none; cursor: pointer; color: var(--text-muted); + padding: 0; display: flex; align-items: center; +} +.eye-toggle svg { width: 18px; height: 18px; } + +/* ── Buttons ─────────────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: .4rem; + padding: .6rem 1.25rem; border: none; border-radius: var(--radius); + font-family: var(--font); font-size: .9rem; font-weight: 500; + cursor: pointer; transition: all .15s; text-decoration: none; +} +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-h); } +.btn-sky { background: var(--sky); color: #fff; } +.btn-green { background: var(--green); color: #fff; } +.btn-red { background: var(--red); color: #fff; } +.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); } +.btn-ghost:hover { border-color: var(--primary); color: var(--primary); } +.btn-full { width: 100%; justify-content: center; padding: .75rem; } +.btn:disabled { opacity: .6; cursor: not-allowed; } +.btn-sm { padding: .35rem .8rem; font-size: .82rem; } +.btn-icon { padding: .5rem; border-radius: 8px; } + +/* ── Alerts ──────────────────────────────────────────────────────────────────── */ +.alert { padding: .75rem 1rem; border-radius: var(--radius); font-size: .875rem; margin-bottom: 1rem; } +.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #fca5a5; } +.alert-success { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6ee7b7; } +.alert-warning { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.3); color: #fcd34d; } +.alert-info { background: rgba(99,102,241,.12); border: 1px solid rgba(99,102,241,.3); color: #a5b4fc; } + +/* ── Panel Layout ────────────────────────────────────────────────────────────── */ +.panel-layout { display: flex; min-height: 100vh; } + +.sidebar { + width: 240px; min-width: 240px; background: var(--bg2); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + position: fixed; height: 100vh; overflow-y: auto; z-index: 100; +} +.sidebar-brand { + display: flex; align-items: center; gap: .6rem; + padding: 1.25rem 1.25rem 1rem; + border-bottom: 1px solid var(--border); +} +.sidebar-brand .logo-text { font-size: 1.1rem; } +.sidebar-brand .logo-icon { width: 28px; height: 28px; } + +.sidebar-section { padding: .75rem 0; } +.sidebar-section-label { + font-size: .7rem; font-weight: 700; letter-spacing: .08em; + text-transform: uppercase; color: var(--text-muted); + padding: .25rem 1.25rem .5rem; +} +.sidebar-link { + display: flex; align-items: center; gap: .75rem; + padding: .55rem 1.25rem; text-decoration: none; + color: var(--text-muted); font-size: .88rem; + border-left: 3px solid transparent; + transition: all .12s; +} +.sidebar-link:hover { color: var(--text); background: var(--bg3); } +.sidebar-link.active { color: var(--primary); background: rgba(99,102,241,.1); border-left-color: var(--primary); } +.sidebar-link svg { width: 18px; height: 18px; flex-shrink: 0; } + +.sidebar-user { + margin-top: auto; padding: 1rem 1.25rem; + border-top: 1px solid var(--border); +} +.sidebar-user-info { display: flex; align-items: center; gap: .75rem; } +.avatar { + width: 36px; height: 36px; border-radius: 50%; + background: linear-gradient(135deg, var(--primary), var(--sky)); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: .9rem; flex-shrink: 0; +} +.user-name { font-size: .88rem; font-weight: 600; } +.user-role { font-size: .75rem; color: var(--text-muted); text-transform: capitalize; } + +.main-content { margin-left: 240px; flex: 1; display: flex; flex-direction: column; } + +.topbar { + background: var(--bg2); border-bottom: 1px solid var(--border); + padding: .75rem 1.5rem; display: flex; align-items: center; + gap: 1rem; position: sticky; top: 0; z-index: 50; +} +.topbar-title { font-size: 1rem; font-weight: 600; flex: 1; } +.topbar-actions { display: flex; align-items: center; gap: .5rem; } + +.page-content { padding: 1.5rem; flex: 1; } + +/* ── Cards ───────────────────────────────────────────────────────────────────── */ +.card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; +} +.card-header { + padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: .75rem; +} +.card-title { font-size: .95rem; font-weight: 600; flex: 1; } +.card-body { padding: 1.25rem; } + +/* ── Stats Cards ─────────────────────────────────────────────────────────────── */ +.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } +.stat-card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.25rem; +} +.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin-bottom: .5rem; } +.stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1; } +.stat-sub { font-size: .78rem; color: var(--text-muted); margin-top: .3rem; } +.stat-green { color: var(--green); } +.stat-red { color: var(--red); } +.stat-yellow{ color: var(--yellow); } +.stat-blue { color: var(--sky); } + +/* ── Progress bar ────────────────────────────────────────────────────────────── */ +.progress { background: var(--bg3); border-radius: 999px; height: 6px; overflow: hidden; } +.progress-bar { height: 100%; border-radius: 999px; transition: width .3s; } +.progress-bar.green { background: var(--green); } +.progress-bar.yellow { background: var(--yellow); } +.progress-bar.red { background: var(--red); } + +/* ── Tables ──────────────────────────────────────────────────────────────────── */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .88rem; } +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); } +td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--bg3); } + +/* ── Badges ──────────────────────────────────────────────────────────────────── */ +.badge { display: inline-block; padding: .2rem .55rem; border-radius: 999px; font-size: .72rem; font-weight: 600; } +.badge-green { background: rgba(16,185,129,.15); color: #6ee7b7; } +.badge-red { background: rgba(239,68,68,.15); color: #fca5a5; } +.badge-yellow { background: rgba(245,158,11,.15); color: #fcd34d; } +.badge-blue { background: rgba(99,102,241,.15); color: #a5b4fc; } +.badge-sky { background: rgba(14,165,233,.15); color: #7dd3fc; } +.badge-gray { background: rgba(148,163,184,.15); color: #94a3b8; } + +/* ── Modal ───────────────────────────────────────────────────────────────────── */ +.modal-overlay { + display: none; position: fixed; inset: 0; + background: rgba(0,0,0,.7); z-index: 1000; + align-items: center; justify-content: center; +} +.modal-overlay.open { display: flex; } +.modal { + background: var(--bg2); border: 1px solid var(--border); + border-radius: 14px; width: 100%; max-width: 500px; + max-height: 90vh; overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,.6); +} +.modal-header { + padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border); + display: flex; align-items: center; +} +.modal-title { font-size: 1rem; font-weight: 600; flex: 1; } +.modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.25rem; } +.modal-body { padding: 1.5rem; } +.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; } + +/* ── Tabs ────────────────────────────────────────────────────────────────────── */ +.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; } +.tab-btn { + padding: .65rem 1.25rem; border: none; background: none; + color: var(--text-muted); font-size: .88rem; cursor: pointer; + border-bottom: 2px solid transparent; margin-bottom: -1px; + transition: color .12s; +} +.tab-btn:hover { color: var(--text); } +.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); } +.tab-pane { display: none; } +.tab-pane.active { display: block; } + +/* ── Grid helpers ────────────────────────────────────────────────────────────── */ +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; } +.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; } +@media (max-width: 768px) { + .sidebar { transform: translateX(-100%); transition: transform .2s; } + .sidebar.open { transform: translateX(0); } + .main-content { margin-left: 0; } + .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; } +} + +/* ── Services status ─────────────────────────────────────────────────────────── */ +.service-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.service-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); } +.service-dot.inactive { background: var(--red); } +.service-dot.unknown { background: var(--text-muted); } + +/* ── Code / Terminal ─────────────────────────────────────────────────────────── */ +code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em; background: var(--bg3); padding: .15em .4em; border-radius: 4px; } +.terminal { + background: #050508; border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem; font-family: monospace; font-size: .82rem; line-height: 1.7; + color: #a6e22e; max-height: 300px; overflow-y: auto; +} + +/* ── Scrollbar ───────────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; } + +/* ── Utility ─────────────────────────────────────────────────────────────────── */ +.flex { display: flex; } .items-center { align-items: center; } .justify-between { justify-content: space-between; } +.gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; } .gap-3 { gap: 1.5rem; } +.mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; } +.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; } +.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; } +} diff --git a/panel/assets/img/nova-favicon.svg b/panel/assets/img/nova-favicon.svg new file mode 100644 index 0000000..4476ee0 --- /dev/null +++ b/panel/assets/img/nova-favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/panel/assets/img/nova-icons.svg b/panel/assets/img/nova-icons.svg new file mode 100644 index 0000000..b00b95e --- /dev/null +++ b/panel/assets/img/nova-icons.svg @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/panel/assets/img/nova-logo.svg b/panel/assets/img/nova-logo.svg new file mode 100644 index 0000000..bbe4464 --- /dev/null +++ b/panel/assets/img/nova-logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + Nova + CPX + diff --git a/panel/assets/img/nova-mark.svg b/panel/assets/img/nova-mark.svg new file mode 100644 index 0000000..1ee7a38 --- /dev/null +++ b/panel/assets/img/nova-mark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/panel/assets/js/admin.js b/panel/assets/js/admin.js index 2249b14..3cd46b4 100644 --- a/panel/assets/js/admin.js +++ b/panel/assets/js/admin.js @@ -1006,7 +1006,7 @@ const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } }); Nova.loadingDone(); if (res?.success) { - window.location.href = res.data?.portal_url || location.origin + "/"; + window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/'; } else { Nova.toast(res?.message || 'Impersonation failed', 'error'); } diff --git a/panel/assets/js/features.js b/panel/assets/js/features.js new file mode 100644 index 0000000..fe2da07 --- /dev/null +++ b/panel/assets/js/features.js @@ -0,0 +1,133 @@ +/** + * NovaCPX Feature Manager + * Loaded by admin panel's features page + */ +window.FeaturesManager = { + async load() { + const res = await Nova.api('features', 'list'); + if (!res?.success) return '

Failed to load features.

'; + const grouped = res.data; + + const categoryIcons = { + 'Web Server':'🌐', 'PHP':'⚙️', 'Database':'🗄️', 'Email':'📧', + 'DNS':'🔍', 'FTP':'📁', 'SSL':'🔒', 'Security':'🛡️', 'Containers':'🐳', + 'IP Management':'🌍', 'Monitoring':'📊', 'Backup':'💾', 'CDN & Performance':'⚡', + 'Development':'👨‍💻', 'One-Click Apps':'🚀', 'Applications':'📦', + 'Billing':'💳', 'Reseller':'🏪', 'Notifications':'🔔', 'Compliance':'✅', + }; + + return ` +
+

Feature Manager

+
+ + +
+
+ +
+ ${Object.entries(grouped).map(([cat, feats]) => ` +
+
+ ${categoryIcons[cat] || '🔧'} +

${cat}

+ ${feats.length} +
+
+ ${feats.map(f => ` +
+
+
+
+
${f.name}
+
${f.description}
+ ${f.min_ram_mb > 0 ? `
Requires ${f.min_ram_mb}MB RAM
` : ''} +
+
+ ${f.installed + ? `` + : `` + } + ${f.installed ? `Installed` : `Not installed`} +
+
+
+
`).join('')} +
+
`).join('')} +
+ +`; + }, + + async toggle(id, enable) { + const res = await Nova.api('features', 'toggle', { method: 'POST', body: { id, enable } }); + if (res?.data?.action === 'install_required') { + Nova.confirm(`"${res.data.feature.name}" must be installed first. Install now?`, () => this.install(id)); + return; + } + Nova.toast(res?.message || 'Updated', res?.success ? 'success' : 'error'); + }, + + async install(id) { + const res = await Nova.api('features', 'install', { method: 'POST', body: { id } }); + if (!res?.success) { Nova.toast(res?.message || 'Install failed', 'error'); return; } + + const logDiv = `
Starting installation…
`; + const ov = Nova.modal('Installing Feature', logDiv); + const poll = setInterval(async () => { + const logRes = await Nova.api('features', 'install-log', { params: { id } }); + const logEl = document.getElementById(`install-log-${id}`); + if (logEl) logEl.innerHTML = (logRes?.data?.log || '').split('\n').map(l => `
${l}
`).join(''); + if (!logRes?.data?.running) { + clearInterval(poll); + if (logRes?.data?.installed) { + Nova.toast('Feature installed successfully', 'success'); + ov.remove(); + document.getElementById('page-content').innerHTML = await this.load(); + this.bindSearch(); + } + } + }, 2000); + }, + + bindSearch() { + const search = document.getElementById('feat-search'); + const catFilter = document.getElementById('feat-cat-filter'); + if (!search || !catFilter) return; + + const filter = () => { + const q = search.value.toLowerCase(); + const cat = catFilter.value; + document.querySelectorAll('.feat-card').forEach(c => { + const match = (!q || c.dataset.name.includes(q)) && (!cat || c.dataset.cat === cat); + c.closest('div').style.display = match ? '' : 'none'; + }); + document.querySelectorAll('.feat-category').forEach(sec => { + const visible = [...sec.querySelectorAll('.feat-card')].some(c => c.closest('div').style.display !== 'none'); + sec.style.display = visible ? '' : 'none'; + }); + }; + search.addEventListener('input', filter); + catFilter.addEventListener('change', filter); + }, +}; diff --git a/panel/assets/js/nova.js b/panel/assets/js/nova.js new file mode 100644 index 0000000..c3551bf --- /dev/null +++ b/panel/assets/js/nova.js @@ -0,0 +1,234 @@ +/** + * NovaCPX — Shared JS utilities + */ + +window.Nova = (() => { + // ── Activity bar (thin top-of-page progress stripe for every API call) ──── + let _barEl = null, _barPct = 0, _barTimer = null, _barActive = 0; + function _barShow() { + _barActive++; + if (!_barEl) { + _barEl = document.createElement('div'); + _barEl.style.cssText = [ + 'position:fixed;top:0;left:0;z-index:999999', + 'height:3px;width:0%;background:var(--primary,#6366f1)', + 'transition:width .2s ease,opacity .3s ease', + 'box-shadow:0 0 8px var(--primary,#6366f1)', + 'pointer-events:none', + ].join(';'); + document.body.appendChild(_barEl); + } + _barEl.style.opacity = '1'; + _barPct = 10; + _barEl.style.width = _barPct + '%'; + clearInterval(_barTimer); + _barTimer = setInterval(() => { + if (_barEl && _barPct < 85) { _barPct += (_barPct < 50 ? 8 : _barPct < 70 ? 4 : 1); _barEl.style.width = _barPct + '%'; } + }, 200); + } + function _barDone() { + _barActive = Math.max(0, _barActive - 1); + if (_barActive > 0) return; + clearInterval(_barTimer); + if (_barEl) { + _barEl.style.width = '100%'; + setTimeout(() => { if (_barEl) { _barEl.style.opacity = '0'; setTimeout(() => { _barEl?.remove(); _barEl = null; }, 300); } }, 200); + } + } + + // ── API ─────────────────────────────────────────────────────────────────── + async function api(endpoint, action, opts = {}) { + const { method = 'GET', body, params } = opts; + let url = `/api/${endpoint}/${action}`; + if (params) url += '?' + new URLSearchParams(params); + _barShow(); + let res; + try { + res = await fetch(url, { + method, + credentials: 'include', + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (e) { + _barDone(); + console.error(`Nova.api network error [${endpoint}/${action}]:`, e); + return { success: false, message: 'Network error — check your connection' }; + } + _barDone(); + if (res.status === 401) { return { success: false, message: 'Session expired — please log in again' }; } + if (res.status === 429) { + const reset = res.headers.get('X-RateLimit-Reset'); + const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60; + return { success: false, message: `Rate limited — try again in ${wait}s` }; + } + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + console.error(`Nova.api non-JSON from [${endpoint}/${action}] (HTTP ${res.status}):`, text.slice(0, 500)); + return { success: false, message: `Server error (HTTP ${res.status}) — see browser console` }; + } + } + + // ── Toast ───────────────────────────────────────────────────────────────── + let toastEl = null; + function toast(msg, type = 'info', duration = 3500) { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.style.cssText = 'position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;max-width:380px'; + document.body.appendChild(toastEl); + } + const el = document.createElement('div'); + el.className = `alert alert-${type}`; + el.style.cssText = 'animation:fadeIn .2s;cursor:pointer;box-shadow:var(--shadow)'; + el.textContent = msg; + el.addEventListener('click', () => el.remove()); + toastEl.appendChild(el); + setTimeout(() => el.remove(), duration); + } + + // ── Modal ───────────────────────────────────────────────────────────────── + function modal(title, bodyHtml, footerHtml = '') { + const ov = document.createElement('div'); + ov.className = 'modal-overlay open'; + ov.innerHTML = ``; + ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); }); + document.body.appendChild(ov); + return ov; + } + + // ── Confirm dialog ──────────────────────────────────────────────────────── + function confirm(msg, onYes, danger = false) { + const ov = modal('Confirm', `

${msg}

`, + ` + ` + ); + ov.querySelector('#confirm-yes').onclick = () => { ov.remove(); onYes(); }; + } + + // ── Sidebar navigation ──────────────────────────────────────────────────── + function initNav(pages) { + document.querySelectorAll('[data-page]').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const page = link.dataset.page; + document.querySelectorAll('[data-page]').forEach(l => l.classList.remove('active')); + link.classList.add('active'); + const titleEl = document.getElementById('page-title'); + if (titleEl) titleEl.textContent = link.textContent.trim(); + loadPage(page, pages); + }); + }); + } + + function loadPage(page, pages) { + const content = document.getElementById('page-content'); + if (!content) return; + const fn = pages[page]; + if (fn) { + content.innerHTML = '
Loading…
'; + Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; }); + } else { + content.innerHTML = `

Page "${page}" coming soon.

`; + } + } + + // ── Progress bar helper ─────────────────────────────────────────────────── + function progressBar(pct) { + const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green'; + return `
`; + } + + // ── Format helpers ──────────────────────────────────────────────────────── + function bytes(n) { + if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB'; + if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'; + if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'; + return n + ' B'; + } + function relTime(dateStr) { + const diff = (Date.now() - new Date(dateStr)) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; + } + function badge(text, type = 'blue') { + return `${text}`; + } + function serviceDot(status) { + const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown'; + return ``; + } + + function escHtml(str) { + return String(str ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); + } + + // ── Loading overlay ─────────────────────────────────────────────────────── + let _loadingEl = null; + let _loadingCount = 0; + function loading(msg = 'Working…') { + _loadingCount++; + if (!_loadingEl) { + _loadingEl = document.createElement('div'); + _loadingEl.id = 'nova-loading-overlay'; + _loadingEl.style.cssText = [ + 'position:fixed;inset:0;z-index:99999', + 'background:rgba(0,0,0,.55)', + 'display:flex;flex-direction:column;align-items:center;justify-content:center', + 'gap:1rem;animation:fadeIn .15s', + ].join(';'); + _loadingEl.innerHTML = ` +
+
${escHtml(msg)}
`; + document.body.appendChild(_loadingEl); + } else { + document.getElementById('nova-loading-msg').textContent = msg; + } + } + function loadingDone() { + _loadingCount = Math.max(0, _loadingCount - 1); + if (_loadingCount === 0 && _loadingEl) { + _loadingEl.remove(); + _loadingEl = null; + } + } + + // Inject global CSS animations + const style = document.createElement('style'); + style.textContent = [ + '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}', + '@keyframes ncpxSpin{to{transform:rotate(360deg)}}', + ].join(''); + document.head.appendChild(style); + + return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone }; +})(); + +// #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(); }) + ); +}); diff --git a/panel/assets/js/reseller.js b/panel/assets/js/reseller.js new file mode 100644 index 0000000..8c8de93 --- /dev/null +++ b/panel/assets/js/reseller.js @@ -0,0 +1,700 @@ +/** + * NovaCPX Reseller Panel JS + */ + +let _rUser = null; + +async function initReseller() { + const res = await Nova.api('auth', 'me'); + if (!res?.success || !['admin','reseller'].includes(res.data?.role)) { + document.getElementById('auth-check').innerHTML = renderLogin(); + document.getElementById('main-layout').style.display = 'none'; + return false; + } + _rUser = res.data; + document.getElementById('user-name').textContent = _rUser.username || 'Reseller'; + document.getElementById('auth-check').style.display = 'none'; + document.getElementById('main-layout').style.display = ''; + return true; +} + +function renderLogin() { + return ``; +} + +async function doLogin() { + Nova.loading('Signing in…'); + const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }}); + Nova.loadingDone(); + if (res?.success) { + if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url; + else location.reload(); + } else { + const err = document.getElementById('li-err'); + if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; } + } +} +window.doLogin = doLogin; + +/* ── Pages ─────────────────────────────────────────────────────────────── */ + +async function rDashboard(el) { + el.innerHTML = ` +
Loading…
+
+
Recent Accounts + +
+
Loading…
+
`; + + const res = await Nova.api('accounts', 'list', { params:{ limit:5 }}); + const accts = res?.data || []; + + document.getElementById('r-stats').innerHTML = [ + { label: 'Total Accounts', val: res?.meta?.total || accts.length, icon: 'ni-accounts' }, + { label: 'Active', val: accts.filter(a=>a.status==='active').length, icon: 'ni-stats' }, + { label: 'Suspended', val: accts.filter(a=>a.status==='suspended').length, icon: 'ni-suspend' }, + ].map(s => `
+ +
${s.val}
${s.label}
+
`).join(''); + + document.getElementById('r-recent').innerHTML = accts.length + ? ` + ${accts.map(a => ` + + + `).join('')} +
UsernameDomainPackageStatus
${a.username}${a.domain}${a.package_name||'—'}${Nova.badge(a.status, a.status==='active'?'green':'yellow')}
` + : '
No accounts yet.
'; +} + +async function rAccounts(el) { + el.innerHTML = ` +
+
+ +
+
Loading…
+
`; + loadRAccounts(); +} + +async function loadRAccounts(search = '') { + const el = document.getElementById('r-accounts-list'); + if (!el) return; + const res = await Nova.api('accounts', 'list', { params: search ? { search } : {}}); + const acctRows = res?.data || []; + if (!res?.success || !acctRows.length) { el.innerHTML = '
No accounts found.
'; return; } + el.innerHTML = ` + ${acctRows.map(a => ` + + + + + + + `).join('')} +
UsernameDomainPackageDiskStatusActions
${Nova.escHtml(a.username)}${Nova.escHtml(a.domain)}${a.package_name ? Nova.escHtml(a.package_name) : '—'}${a.disk_usage_mb || 0} MB${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')} + + ${a.status === 'active' + ? `` + : ``} + + +
`; +} +window.loadRAccounts = loadRAccounts; +window.rSearchAccounts = (v) => loadRAccounts(v); + +window.rLoginAs = async (userId, username) => { + Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => { + Nova.loading(`Switching to ${username}…`); + const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } }); + Nova.loadingDone(); + if (res?.success) { + window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/'; + } else { + Nova.toast(res?.message || 'Impersonation failed', 'error'); + } + }); +}; + +window.rSuspend = async (id, user) => { + Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => { + const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }}); + if (res?.success) { Nova.toast('Account suspended','success'); loadRAccounts(); } + else Nova.toast(res?.message,'error'); + }); +}; +window.rUnsuspend = async (id, user) => { + const res = await Nova.api('accounts', 'unsuspend', { method:'POST', body:{ account_id: id }}); + if (res?.success) { Nova.toast('Account unsuspended','success'); loadRAccounts(); } + else Nova.toast(res?.message,'error'); +}; +window.rTerminate = (id, user) => { + Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, databases, DNS, and email. THIS CANNOT BE UNDONE.`, async () => { + const res = await Nova.api('accounts', 'terminate', { method:'POST', body:{ account_id: id }}); + if (res?.success) { Nova.toast('Account terminated','success'); loadRAccounts(); } + else Nova.toast(res?.message,'error'); + }, true); +}; +window.rChangePass = (id, user) => { + Nova.modal(`Change Password — ${user}`, `
`, + ``); +}; + +async function rCreateAccount(el) { + el.innerHTML = ` +
+
+
+
+
+
+
+
+ + +
+
+
+
`; + + Nova.api('packages', 'list').then(res => { + const sel = document.getElementById('ca-pkg'); + if (sel && res?.success) { + sel.innerHTML = res.data.map(p => ``).join(''); + } + }); +} + +window.submitCreateAccount = async () => { + const btn = document.querySelector('#ca-result'); + if (btn) btn.textContent = ''; + Nova.loading('Creating hosting account…'); + const res = await Nova.api('accounts', 'create', { method:'POST', body:{ + username: document.getElementById('ca-user')?.value, + password: document.getElementById('ca-pass')?.value, + email: document.getElementById('ca-email')?.value, + domain: document.getElementById('ca-domain')?.value, + package_id: document.getElementById('ca-pkg')?.value, + }}); + Nova.loadingDone(); + if (res?.success) { + Nova.toast('Account created successfully!','success'); + if (btn) btn.innerHTML = `
Account created! View accounts →
`; + } else { + Nova.toast(res?.message || 'Failed to create account','error'); + if (btn) btn.innerHTML = `
${res?.message || 'Error'}
`; + } +}; + +async function rPackages(el) { + el.innerHTML = ` +
Loading…
`; + + const res = await Nova.api('packages', 'list'); + const plist = document.getElementById('pkg-list'); + if (!res?.success || !res.data.length) { plist.innerHTML = '
No packages yet.
'; return; } + plist.innerHTML = ` + ${res.data.map(p => ` + + + + + + + + + `).join('')} +
NameDiskBWDBsEmailsDomainsPriceActions
${p.name}${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'}${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'}${p.databases || '∞'}${p.email_accounts || '∞'}${p.addon_domains || '∞'}${p.price ? '$'+p.price : 'Free'} + + +
`; +} + +window.rAddPackage = () => showPackageModal(); +window.rEditPackage = async (id) => { + const res = await Nova.api('packages', 'get', { params:{ id }}); + if (res?.success) showPackageModal(res.data); +}; +function showPackageModal(pkg = null) { + const p = pkg || {}; + Nova.modal(pkg ? 'Edit Package' : 'Add Package', ` +
+
+
+
+
+
+
+
+
+
+
`, + ``); +} +window.submitPackage = async (id) => { + const body = { name:document.getElementById('pk-name')?.value, disk_mb:parseInt(document.getElementById('pk-disk')?.value), bandwidth_mb:parseInt(document.getElementById('pk-bw')?.value), databases:parseInt(document.getElementById('pk-db')?.value), email_accounts:parseInt(document.getElementById('pk-email')?.value), addon_domains:parseInt(document.getElementById('pk-adom')?.value), subdomains:parseInt(document.getElementById('pk-sub')?.value), ftp_accounts:parseInt(document.getElementById('pk-ftp')?.value), price:parseFloat(document.getElementById('pk-price')?.value) }; + const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body}); + if (res?.success) { Nova.toast(id ? 'Package updated' : 'Package created','success'); document.querySelector('.modal-overlay')?.remove(); rPackages(document.getElementById('page-content')); } + else Nova.toast(res?.message,'error'); +}; +window.rDeletePackage = (id, name) => { + Nova.confirm(`Delete package "${name}"? Cannot delete if accounts are using it.`, async () => { + const res = await Nova.api('packages','delete',{method:'POST',body:{id}}); + if (res?.success) { Nova.toast('Deleted','success'); rPackages(document.getElementById('page-content')); } + else Nova.toast(res?.message,'error'); + }, true); +}; + +async function rDNS(el) { + el.innerHTML = ` +
Loading…
`; + const res = await Nova.api('dns', 'zones'); + const list = document.getElementById('r-dns-list'); + if (!res?.success || !res.data.length) { list.innerHTML = '
No DNS zones.
'; return; } + list.innerHTML = ` + ${res.data.map(z => ` + + + + + `).join('')} +
DomainAccountRecordsActions
${z.domain}${z.username||'—'}${z.record_count||0}
`; +} + +window.rViewZone = async (zoneId, domain) => { + const res = await Nova.api('dns', 'records', { params:{ zone_id: zoneId }}); + if (!res?.success) { Nova.toast('Failed to load records','error'); return; } + const rows = res.data.map(r => ` + ${r.name}${Nova.badge(r.type,'default')}${r.value}${r.ttl} + + `).join(''); + Nova.modal(`DNS Records — ${domain}`, + ` + ${rows}
NameTypeValueTTL
`); +}; +window.rAddRecord = (zoneId, domain) => { + Nova.modal('Add DNS Record', ` +
+
+
+
+
`, + ``); +}; +window.rDeleteRecord = async (id, zoneId, domain) => { + Nova.confirm('Delete this DNS record?', async () => { + const res = await Nova.api('dns', 'delete-record', { method:'POST', body:{id, zone_id: zoneId }}); + if (res?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); rViewZone(zoneId, domain); } + else Nova.toast(res?.message,'error'); + }, true); +}; + +/* ── Nav ────────────────────────────────────────────────────────────────── */ +const rNavGroups = [ + { label: 'Overview', items: [ + { id: 'dashboard', label: 'Dashboard', + svg: '' }, + ]}, + { label: 'Accounts', items: [ + { id: 'accounts', label: 'All Accounts', + svg: '' }, + { id: 'createAccount', label: 'New Account', + svg: '' }, + { id: 'packages', label: 'Packages', + svg: '' }, + ]}, + { label: 'DNS', items: [ + { id: 'dns', label: 'DNS Zones', + svg: '' }, + ]}, + { label: 'Tools', items: [ + { id: 'docker', label: 'Docker', + svg: '' }, + { id: 'whitelabel', label: 'White Label', + svg: '' }, + ]}, +]; +const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel }; + +let _rActivePage = 'dashboard'; + +function renderRNav() { + const nav = document.getElementById('sidebar-nav'); + if (!nav) return; + nav.innerHTML = rNavGroups.map(g => ` + `).join(''); + + nav.querySelectorAll('[data-page]').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + if (window.innerWidth <= 768) { + document.getElementById('sidebar')?.classList.remove('open'); + document.getElementById('sidebar-overlay')?.classList.remove('open'); + document.body.style.overflow = ''; + } + resellerNav(link.dataset.page); + }); + }); +} + +window.resellerNav = (page) => { + _rActivePage = page; + renderRNav(); + const allItems = rNavGroups.flatMap(g => g.items); + const item = allItems.find(n => n.id === page); + const titleEl = document.getElementById('page-title'); + if (titleEl && item) titleEl.textContent = item.label; + const content = document.getElementById('page-content'); + if (!content) return; + content.innerHTML = '
Loading…
'; + if (rPages[page]) rPages[page](content); +}; + +document.addEventListener('DOMContentLoaded', async () => { + const ok = await initReseller(); + if (!ok) return; + document.getElementById('logout-btn')?.addEventListener('click', async e => { + e.preventDefault(); + await Nova.api('auth', 'logout', { method: 'POST' }); + location.href = '/'; + }); + renderRNav(); + window.resellerNav('dashboard'); +}); + +/* ── Docker (Reseller #33) ────────────────────────────────────────────────── */ +async function rDocker(el) { + el.innerHTML = '
Loading…
'; + const [stRes, acctRes] = await Promise.all([ + Nova.api('docker', 'stacks'), + Nova.api('accounts', 'list', { params: { limit: 200 } }), + ]); + const stacks = stRes?.data?.stacks || []; + const accts = acctRes?.data || []; + + el.innerHTML = ` + +

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

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

Set Docker limits for each of your customers.

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

Pre-install app stacks for your customers.

+
+${Object.entries(catalog).map(([key,app])=>` +
+
+
${Nova.escHtml(app.icon)}
+
${Nova.escHtml(app.name)}
+
${Nova.escHtml(app.description)}
+ +
+
`).join('')} +
`; + } +} + +window.rDockerAct = async (cid, action) => { + Nova.loading(`${action.charAt(0).toUpperCase()+action.slice(1)}ing container…`); + const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } }); + Nova.loadingDone(); + Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error'); + if (r?.success) rDockerLoadTab('containers'); +}; + +window.rDockerLogs = async (cid, name) => { + const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } }); + Nova.modal(`Logs: ${name}`, `
${Nova.escHtml(r?.data?.logs||'')}
`); +}; + +window.rDockerQuotaModal = (userId, username) => { + const ov = Nova.modal(`Docker Quota: ${username}`, + `
+
+
`, + ` + ` + ); + window.rDockerQuotaSubmit = async (uid) => { + ov.remove(); + const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{ + user_id: uid, + max_containers: parseInt(document.getElementById('rdq-cnt').value)||2, + max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512, + max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0, + }}); + Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error'); + }; +}; + +window.rDockerLaunchModal = async (appKey, appName) => { + const catRes = await Nova.api('docker', 'catalog'); + const app = catRes?.data?.catalog?.[appKey]; + if (!app) return; + const accts = window._rDockerAccts || []; + const acctOpts = accts.map(a=>``).join(''); + const paramFields = (app.params||[]).map(p=>` +
+
`).join(''); + const ov = Nova.modal(`Deploy ${appName}`, + `
${paramFields}`, + ` + ` + ); + window.rDockerLaunchSubmit = async (key) => { + const acctId = parseInt(document.getElementById('rl-acct').value)||0; + if (!acctId) { Nova.toast('Select an account','error'); return; } + const params = {}; + (app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; }); + ov.remove(); + Nova.loading(`Deploying ${appName}… this may take a minute`); + const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }}); + Nova.loadingDone(); + Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error'); + if (r?.success) rDockerLoadTab('containers'); + }; +}; + +// ── White Label / Branding (#18) ──────────────────────────────────────────── +async function rWhiteLabel(el) { + el.innerHTML = '
Loading…
'; + const res = await Nova.api('branding', 'get'); + const b = res?.data || {}; + + el.innerHTML = ` + +
+ +
+
Panel Identity
+
+
+ + +
+
+ + ${b.logo_url ? `
` : ''} +
+ + ${b.logo_url ? `` : ''} + PNG/SVG/JPG · max 512 KB +
+
+
+ + +
+
+
+ +
+
+
Colors
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+ +
+
Support
+
+
+ + +
+
+ + +
+ +
+
+ +
+ + +
+
+
`; + + // Sync color pickers ↔ hex inputs ↔ preview + ['primary','accent'].forEach(k => { + const picker = document.getElementById('wl-'+k); + const hex = document.getElementById('wl-'+k+'-hex'); + const sync = () => { + if (picker) hex.value = picker.value; + rWlUpdatePreview(); + }; + const syncBack = () => { + if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; rWlUpdatePreview(); } + }; + picker?.addEventListener('input', sync); + hex?.addEventListener('input', syncBack); + }); +} + +function rWlUpdatePreview() { + const p = document.getElementById('wl-primary-hex')?.value || '#6366f1'; + const a = document.getElementById('wl-accent-hex')?.value || '#0ea5e9'; + const el = document.getElementById('wl-color-preview'); + if (el) el.style.background = `linear-gradient(135deg,${p},${a})`; + // Live-preview CSS vars + const style = document.getElementById('reseller-branding') || (() => { + const s = document.createElement('style'); s.id = 'reseller-branding'; document.head.appendChild(s); return s; + })(); + style.textContent = `:root { --primary: ${p}; --primary-dark: ${p}; --accent: ${a}; }`; +} + +window.rWlUploadLogo = async () => { + const file = document.getElementById('wl-logo-file')?.files?.[0]; + if (!file) return; + if (file.size > 512 * 1024) { Nova.toast('Logo must be under 512 KB', 'error'); return; } + const fd = new FormData(); + fd.append('logo', file); + Nova.toast('Uploading…', 'info', 5000); + try { + const res = await fetch('/api/branding/upload-logo', { + method: 'POST', credentials: 'include', body: fd + }); + const data = await res.json(); + Nova.toast(data?.success ? 'Logo uploaded' : (data?.message || 'Upload failed'), + data?.success ? 'success' : 'error'); + if (data?.success) rWhiteLabel(document.getElementById('page-content')); + } catch (e) { Nova.toast('Upload failed', 'error'); } +}; + +window.rWlDeleteLogo = async () => { + const r = await Nova.api('branding', 'delete-logo', { method: 'POST' }); + Nova.toast(r?.success ? 'Logo removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) rWhiteLabel(document.getElementById('page-content')); +}; + +window.rWlSave = async () => { + const body = { + panel_name: document.getElementById('wl-name')?.value?.trim() || 'NovaCPX', + primary_color: document.getElementById('wl-primary-hex')?.value || '#6366f1', + accent_color: document.getElementById('wl-accent-hex')?.value || '#0ea5e9', + support_email: document.getElementById('wl-email')?.value?.trim() || '', + support_url: document.getElementById('wl-url')?.value?.trim() || '', + hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0, + custom_css: document.getElementById('wl-css')?.value || '', + }; + Nova.loading('Saving branding…'); + const r = await Nova.api('branding', 'save', { method: 'POST', body }); + Nova.loadingDone(); + Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'), + r?.success ? 'success' : 'error'); +}; diff --git a/panel/assets/js/user.js b/panel/assets/js/user.js new file mode 100644 index 0000000..29266a0 --- /dev/null +++ b/panel/assets/js/user.js @@ -0,0 +1,1276 @@ +/** + * 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'; + document.getElementById('auth-check').style.display = 'none'; + document.getElementById('main-layout').style.display = ''; + + // Show impersonation banner if an admin/reseller is acting as this user + if (_user.impersonated_by) { + const imp = _user.impersonated_by; + const returnUrl = imp.role === 'reseller' + ? location.href.replace(/:\d+/, ':8881') + : location.href.replace(/:\d+/, ':8882'); + const banner = document.createElement('div'); + banner.id = 'impersonation-banner'; + banner.style.cssText = [ + 'position:fixed;top:0;left:0;right:0;z-index:99998', + 'background:linear-gradient(135deg,#f59e0b,#d97706)', + 'color:#fff;font-size:.82rem;font-weight:600', + 'display:flex;align-items:center;justify-content:center;gap:1rem', + 'padding:.45rem 1rem', + 'box-shadow:0 2px 8px rgba(0,0,0,.25)', + ].join(';'); + banner.innerHTML = ` + + Acting as ${Nova.escHtml(_user.username)} — logged in as ${Nova.escHtml(imp.username)} (${imp.role}) + `; + document.body.prepend(banner); + // Push content down so the fixed banner doesn't overlap + const layout = document.getElementById('main-layout'); + if (layout) layout.style.marginTop = '36px'; + } + + return true; +} + +window.exitImpersonation = async () => { + Nova.loading('Returning…'); + const res = await Nova.api('auth', 'unimpersonate', { method: 'POST' }); + Nova.loadingDone(); + if (res?.success && res.data?.portal_url) { + window.location.href = res.data.portal_url; + } else { + Nova.toast(res?.message || 'Could not return', 'error'); + } +}; + +function renderLogin() { + return ``; +} + +async function doLogin() { + const u = document.getElementById('li-user')?.value; + const p = document.getElementById('li-pass')?.value; + const err = document.getElementById('li-err'); + Nova.loading('Signing in…'); + const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: u, password: p } }); + Nova.loadingDone(); + 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, + docker: dockerPage, + 'change-password': changePasswordPage, +}; + +/* ── Dashboard ───────────────────────────────────────────────────────────── */ +const _quickIcons = { + domains: '', + email: '', + databases: '', + ftp: '', + ssl: '', + php: '', + cron: '', + files: '', +}; + +async function dashboard(el) { + el.innerHTML = ` +
+ ${['Disk','Databases','Email Accts','FTP Accts'].map(l => `
${l}
`).join('')} +
+
+
Quick Access
+
+ ${[ + ['domains','Domains'],['email','Email'],['databases','Databases'],['ftp','FTP'], + ['ssl','SSL'],['php','PHP'],['cron','Cron Jobs'],['files','File Manager'], + ].map(([page, label]) => ` + `).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 `
+ + + + ${pct}% + +
${item.label}
+
${item.used}${item.unit} / ${item.limit > 0 ? item.limit + item.unit : '∞'}
+
`; + }).join(''); + } +} + +/* ── Domains ────────────────────────────────────────────────────────────── */ +async function domains(el) { + el.innerHTML = ` +
Loading…
`; + + 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 = ` + ${rows.map(d => ` + + + + + `).join('')} +
DomainTypeSSLActions
${d.domain}${Nova.badge(d.type, d.is_primary ? 'primary' : 'default')}${d.ssl_enabled ? Nova.badge('SSL','green') : ``} + ${!d.is_primary ? `` : ''} +
`; +} +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); +}; + +function _sslStream(params, onSuccess) { + const termId = 'ssl-term-' + Date.now(); + Nova.modal(`SSL: ${params.domain}`, ` +
+ Requesting certificate…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + fetch('/api/ssl/issue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[done]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.done) { + const btn = document.getElementById('ssl-term-close'); + if (btn) { + btn.textContent = obj.success ? 'Done ✓' : 'Close'; + btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost'; + if (obj.success) btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); if (onSuccess) onSuccess(); }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); +} + +window.issueSSL = (domainId, domain) => _sslStream({ domain }, () => loadDomainsList()); +window.issueSSL = window.issueSSL; + +/* ── Email ──────────────────────────────────────────────────────────────── */ +async function email(el) { + el.innerHTML = ` +
Loading…
+ +
Loading…
`; + + 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 = ` + ${res.data.map(a => ` + + + + + `).join('')} +
EmailQuotaStatusActions
${a.email}${a.quota_mb > 0 ? a.quota_mb + 'MB' : 'Unlimited'}${Nova.badge(a.status, a.status === 'active' ? 'green' : 'yellow')} + Webmail + + +
`; +} +window.loadEmailList = loadEmailList; + +window.addEmailAccount = async () => { + const dr = await Nova.api('domains', 'list'); + const domains = (dr?.data || []).map(d => d.domain).filter(Boolean); + const domainOpts = domains.length + ? domains.map(d => ``).join('') + : ''; + Nova.modal('Add Email Account', ` +
+ +
+ + @ + +
+
+
+
`, + `` + ); +}; + +window.submitAddEmail = async () => { + const local = (document.getElementById('em-local')?.value || '').trim(); + const domain = document.getElementById('em-domain')?.value || ''; + if (!local) { Nova.toast('Enter a username', 'error'); return; } + if (!domain) { Nova.toast('Select a domain', 'error'); return; } + const res = await Nova.api('email', 'create', { method: 'POST', body: { + email: `${local}@${domain}`, + 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 = ` + ${res.data.map(f => ` + `).join('')} +
FromTo
${f.source}${f.destination}
`; +} + +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 = ` +
Loading…
`; + 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 = ` + ${res.data.map(d => ` + + + + + + `).join('')} +
DatabaseUserTypeSizeActions
${d.db_name}${d.db_user}${Nova.badge(d.db_type,'default')}${d.size || '—'} + + +
`; +} +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 = ` +
Loading…
`; + 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 = ` + ${res.data.map(f => ` + + + + + `).join('')} +
UsernameDirectoryQuotaActions
${f.username}${f.home_dir}${f.quota_mb > 0 ? f.quota_mb+'MB' : 'Unlimited'} + + +
`; +} +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 = ` +
Loading…
`; + 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 = ` + ${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 ` + + + + + + `; + }).join('')} +
DomainTypeExpiresStatusActions
${c.domain}${Nova.badge(c.type,'default')}${c.expires_at || '—'}${Nova.badge(badge, badgeType)} + + +
`; +} +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 = () => { + const domain = document.getElementById('ssl-dom')?.value; + const email = document.getElementById('ssl-email')?.value; + document.querySelector('.modal-overlay')?.remove(); + _sslStream({ domain, email }, () => loadSSLList()); +}; +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 = ` +
+
+
PHP Version
+
Loading…
+
+
+
PHP Settings
+
Loading…
+
+
`; + + const [versRes, cfgRes] = await Promise.all([ + Nova.api('php', 'versions'), + Nova.api('php', 'config'), + ]); + + if (versRes?.success) { + document.getElementById('php-versions').innerHTML = (versRes.data?.versions || []).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) => { + Nova.loading(`Switching to PHP ${ver}…`); + const res = await Nova.api('php', 'switch-version', { method:'POST', body:{ version: ver }}); + Nova.loadingDone(); + if (res?.success) { Nova.toast(`Switched to PHP ${ver}`,'success'); phpPage(document.getElementById('page-content')); } + else Nova.toast(res?.message,'error'); +}; +window.savePHPSettings = async () => { + Nova.loading('Saving PHP settings…'); + 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, + }}); + Nova.loadingDone(); + if (res?.success) Nova.toast('PHP settings saved','success'); + else Nova.toast(res?.message,'error'); +}; + +/* ── Cron Jobs ──────────────────────────────────────────────────────────── */ +async function cron(el) { + el.innerHTML = ` +
Loading…
`; + 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 = ` + ${res.data.map(j => ` + + + + + `).join('')} +
ScheduleCommandStatusActions
${j.minute} ${j.hour} ${j.day} ${j.month} ${j.weekday}${j.command} + + + +
`; +} +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} +
+
Loading…
+
+ `; + + 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 = ` + ${path !== '/' && path !== '/public_html' ? `` : ''} + ${res.data.items.map(f => ` + + + + + + `).join('')} +
NameSizePermsModifiedActions
← ..
+ ${f.type === 'dir' + ? `📁 ${f.name}` + : `📄 ${f.name}`} + ${f.size || '—'}${f.perms}${f.modified} + ${f.type === 'file' ? `` : ''} + + + +
`; +} +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 = `
+
Editing: ${name} +
+ + +
+
+ +
`; +}; +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 = ` +
Loading…
`; + + 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 = ` + +
+
Loading…
+
`; + await loadBackupList(); +} + +async function loadBackupList() { + const el = document.getElementById('backup-list'); + if (!el) return; + const res = await Nova.api('backup', 'list'); + const list = res?.data?.backups || []; + if (!list.length) { + el.innerHTML = `
+ + + + +
No backups yet.
+
Click + Create Backup to create your first backup.
+
`; + return; + } + el.innerHTML = `
+ + + ${list.map(b => ` + + + + + + `).join('')} + +
DateTypeSizeStatusActions
${Nova.relTime(b.created_at)}${Nova.badge(b.type, 'blue')}${b.size ? Nova.bytes(parseInt(b.size)) : '—'}${Nova.badge(b.status, b.status==='complete'?'green':b.status==='running'?'yellow':'red')} + ${b.status === 'complete' + ? `Download` + : ''} +
`; +} + +window.createBackup = () => { + Nova.modal('Create Backup', + `
+ + +
+

Backups run on the server and may take a few minutes for large accounts.

`, + ` + ` + ); +}; + +window.submitCreateBackup = async () => { + const type = document.getElementById('bk-type')?.value || 'full'; + document.querySelector('.modal-overlay')?.remove(); + Nova.loading('Creating backup… this may take a few minutes'); + const res = await Nova.api('backup', 'create', { method: 'POST', body: { type } }); + Nova.loadingDone(); + if (res?.success) { + Nova.toast('Backup created successfully', 'success'); + loadBackupList(); + } else { + Nova.toast(res?.message || 'Backup failed', 'error'); + } +}; + +/* ── Navigation ─────────────────────────────────────────────────────────── */ +const navGroups = [ + { label: 'Overview', items: [ + { id: 'dashboard', label: 'Dashboard', + svg: '' }, + ]}, + { label: 'Hosting', items: [ + { id: 'domains', label: 'Domains', + svg: '' }, + { id: 'email', label: 'Email', + svg: '' }, + { id: 'databases', label: 'Databases', + svg: '' }, + { id: 'ftp', label: 'FTP', + svg: '' }, + { id: 'ssl', label: 'SSL / TLS', + svg: '' }, + ]}, + { label: 'Management', items: [ + { id: 'php', label: 'PHP', + svg: '' }, + { id: 'cron', label: 'Cron Jobs', + svg: '' }, + { id: 'files', label: 'File Manager', + svg: '' }, + { id: 'stats', label: 'Statistics', + svg: '' }, + ]}, + { label: 'Tools', items: [ + { id: 'backups', label: 'Backups', + svg: '' }, + { id: 'docker', label: 'Docker', + svg: '' }, + ]}, + { label: 'Account', items: [ + { id: 'change-password', label: 'Change Password', + svg: '' }, + ]}, +]; + +let _activePage = 'dashboard'; + +function renderNav() { + const nav = document.getElementById('sidebar-nav'); + if (!nav) return; + nav.innerHTML = navGroups.map(g => ` + `).join(''); + + nav.querySelectorAll('[data-page]').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + if (window.innerWidth <= 768) { + document.getElementById('sidebar')?.classList.remove('open'); + document.getElementById('sidebar-overlay')?.classList.remove('open'); + document.body.style.overflow = ''; + } + userNav(link.dataset.page); + }); + }); +} + +window.userNav = (page) => { + _activePage = page; + renderNav(); + const allItems = navGroups.flatMap(g => g.items); + const item = allItems.find(n => n.id === page); + const titleEl = document.getElementById('page-title'); + if (titleEl && item) titleEl.textContent = item.label; + const content = document.getElementById('page-content'); + if (!content) return; + content.innerHTML = '
Loading…
'; + if (userPages[page]) userPages[page](content); +}; + +/* ── Change Password ─────────────────────────────────────────────────────── */ +async function changePasswordPage(el) { + el.innerHTML = ` + +
+
Update Your Password
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
`; +} + +window.submitChangePassword = async () => { + const current = document.getElementById('cp-current')?.value; + const newPass = document.getElementById('cp-new')?.value; + const confirm = document.getElementById('cp-confirm')?.value; + if (!current || !newPass || !confirm) { Nova.toast('All fields required', 'error'); return; } + if (newPass !== confirm) { Nova.toast('New passwords do not match', 'error'); return; } + const res = await Nova.api('auth', 'change-password', { + method: 'POST', + body: { current_password: current, new_password: newPass, confirm_password: confirm }, + }); + if (res?.success) { + Nova.toast('Password updated successfully', 'success'); + document.getElementById('cp-current').value = ''; + document.getElementById('cp-new').value = ''; + document.getElementById('cp-confirm').value = ''; + } else { + Nova.toast(res?.message || 'Failed to update password', 'error'); + } +}; + +/* ── Docker (#34) ────────────────────────────────────────────────────────── */ +async function dockerPage(el) { + el.innerHTML = '
Loading Docker…
'; + const [stackRes, quotaRes, catRes] = await Promise.all([ + Nova.api('docker', 'stacks'), + Nova.api('docker', 'quota-get'), + Nova.api('docker', 'catalog'), + ]); + + const stacks = stackRes?.data?.stacks || []; + const quota = quotaRes?.data?.quota || { max_containers: 2, max_memory_mb: 512, max_cpus: 1.0 }; + const catalog = catRes?.data?.catalog || {}; + + el.innerHTML = ` + +
+
Apps Deployed
${stacks.length} / ${quota.max_containers}
${Nova.progressBar(Math.round(stacks.length/Math.max(quota.max_containers,1)*100))}
+
Max Memory / App
${quota.max_memory_mb} MB
+
Max CPUs / App
${quota.max_cpus}
+
+ +
+ + + +
+
Loading…
`; + + window._uDockerStacks = stacks; + window._uDockerQuota = quota; + window._uDockerCatalog = catalog; + window._uDockerTab = window._uDockerTab || 'my-apps'; + + window.uDockerTab = async (tab) => { + window._uDockerTab = tab; + document.querySelectorAll('[onclick^="uDockerTab"]').forEach(b => { + const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1]; + b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost'); + }); + if (tab === 'my-apps') await uDockerReloadStacks(); + else uDockerLoadTab(tab); + }; + + if (window._uDockerTab === 'my-apps') await uDockerReloadStacks(); + else uDockerLoadTab(window._uDockerTab); +} + +window._uDockerTab = 'my-apps'; + +window.uDockerUninstallAll = () => Nova.confirm( + 'Remove ALL your Docker apps? This will stop and delete every container and stack you own. Your hosting account and websites are not affected.', + async () => { + Nova.loading('Removing all your Docker apps…'); + const r = await Nova.api('docker', 'uninstall-account', { method: 'POST', body: {} }); + Nova.loadingDone(); + Nova.toast(r?.success ? 'All Docker apps removed' : (r?.error || r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) await uDockerReloadStacks(); + }, + true +); + +async function uDockerReloadStacks() { + const r = await Nova.api('docker', 'stacks'); + window._uDockerStacks = r?.data?.stacks || []; + uDockerLoadTab('my-apps'); +} + +function uDockerLoadTab(tab) { + const tc = document.getElementById('udocker-content'); + if (!tc) return; + const stacks = window._uDockerStacks || []; + const catalog = window._uDockerCatalog || {}; + const quota = window._uDockerQuota || {}; + + if (tab === 'my-apps') { + const statusColor = s => s==='running'?'green':s==='starting'?'yellow':s==='stopped'?'red':'yellow'; + tc.innerHTML = ` +
+ ${stacks.length} app${stacks.length===1?'':'s'} +
+ + +
+
+${stacks.length === 0 + ? `
+
🐳
+

No apps yet. Launch one from the catalog!

+ +
` + : `
+${stacks.map(s=>` + + + + +`).join('')} +
AppStatusCreatedActions
${Nova.escHtml(s.name)}${Nova.badge(s.status, statusColor(s.status))}${Nova.relTime(s.created_at)} + ${s.status==='running' + ? `` + : ``} + + + +
`}`; + + } else if (tab === 'catalog') { + tc.innerHTML = ` +

One-click app deployment. Each app runs as an isolated Docker container.

+
+${Object.entries(catalog).map(([key,app])=>` +
+
+
${Nova.escHtml(app.icon)}
+
${Nova.escHtml(app.name)}
+
${Nova.escHtml(app.description)}
+ +
+
`).join('')} +
`; + } +} + +window.uStackAct = async (stackId, action) => { + const label = action === 'up' ? 'Starting' : 'Stopping'; + Nova.loading(`${label} app…`); + const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: stackId, action } }); + Nova.loadingDone(); + Nova.toast(r?.success ? `App ${action==='up'?'started':'stopped'}` : (r?.message||'Failed'), r?.success?'success':'error'); + if (r?.success) await uDockerReloadStacks(); +}; + +window.uStackLogs = async (stackId, name) => { + Nova.loading('Fetching logs…'); + const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: stackId, action: 'logs' } }); + Nova.loadingDone(); + Nova.modal(`Logs: ${name}`, `
${Nova.escHtml(r?.data?.output||'No logs available')}
`); +}; + +window.uStackReinstall = (stackId, name) => Nova.confirm( + `Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`, + async () => { + Nova.loading(`Reinstalling ${name}…`); + const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } }); + Nova.loadingDone(); + Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error'); + if (r?.success) await uDockerReloadStacks(); + }, + true +); + +window.uStackRemove = async (stackId, name) => { + if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return; + Nova.loading('Removing app…'); + const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } }); + Nova.loadingDone(); + Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error'); + if (r?.success) await uDockerReloadStacks(); +}; + +window.uDockerLaunchModal = () => uDockerLaunchApp(null); + +window.uDockerLaunchApp = async (preselect) => { + const catalog = window._uDockerCatalog || {}; + const entries = Object.entries(catalog); + const appOpts = entries.map(([k,a])=>``).join(''); + + window.uDockerUpdateParams = (key) => { + const app = catalog[key]; + if (!app) return; + const tc = document.getElementById('ul-params'); + if (!tc) return; + tc.innerHTML = (app.params||[]).map(p=>` +
+
`).join(''); + }; + + const ov = Nova.modal('Launch App', + `
+
+
`, + ` + ` + ); + + const initialKey = preselect || entries[0]?.[0]; + if (initialKey) uDockerUpdateParams(initialKey); + + window.uDockerLaunchSubmit = async () => { + const key = document.getElementById('ul-app')?.value; + const app = catalog[key]; + if (!app) return; + const params = {}; + (app.params||[]).forEach(p => { params[p.key] = document.getElementById('ul-'+p.key)?.value||''; }); + const missing = (app.params||[]).filter(p=>p.required && !params[p.key]); + if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; } + ov.remove(); + Nova.loading(`Launching ${app.name}… this may take a minute`); + const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } }); + Nova.loadingDone(); + Nova.toast(r?.success ? `${app.name} launching — refresh in a moment to see status` : (r?.message||'Launch failed'), r?.success?'success':'error'); + if (r?.success) { + await uDockerTab('my-apps'); + } + }; +}; + +/* ── Boot ────────────────────────────────────────────────────────────────── */ +document.addEventListener('DOMContentLoaded', async () => { + const ok = await initUser(); + if (!ok) return; + document.getElementById('logout-btn')?.addEventListener('click', async e => { + e.preventDefault(); + await Nova.api('auth', 'logout', { method: 'POST' }); + location.href = '/'; + }); + renderNav(); + window.userNav('dashboard'); +}); diff --git a/panel/errors/404.php b/panel/errors/404.php new file mode 100644 index 0000000..7ffc2c8 --- /dev/null +++ b/panel/errors/404.php @@ -0,0 +1,39 @@ + + + + + +404 — Page Not Found · NovaCPX + + + +
+ +
404
+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

+ + + Go Back + +
+ + diff --git a/panel/errors/500.php b/panel/errors/500.php new file mode 100644 index 0000000..ce2d702 --- /dev/null +++ b/panel/errors/500.php @@ -0,0 +1,39 @@ + + + + + +500 — Server Error · NovaCPX + + + +
+ +
500
+

Internal Server Error

+

Something went wrong on our end. The issue has been logged. Please try again in a moment.

+ + + Go Back + +
+ + diff --git a/panel/index.php b/panel/index.php new file mode 100644 index 0000000..941756d --- /dev/null +++ b/panel/index.php @@ -0,0 +1,130 @@ + + + + + + +NovaCPX — Login + + + + + + + + + + diff --git a/panel/reseller/index.php b/panel/reseller/index.php new file mode 100644 index 0000000..e6d2b05 --- /dev/null +++ b/panel/reseller/index.php @@ -0,0 +1,94 @@ + '?v=' . @filemtime(dirname(__DIR__) . $f); +require_once dirname(__DIR__) . '/_branding.php'; +$_pname = novacpx_panel_name('NovaCPX'); +?> + + + + + + + + +<?= $_pname ?> — Reseller + + + + + + + + + +
+
+
+ + + + + + + + + +
+
Reseller Panel
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + + diff --git a/panel/user/index.php b/panel/user/index.php new file mode 100644 index 0000000..475a786 --- /dev/null +++ b/panel/user/index.php @@ -0,0 +1,155 @@ + '?v=' . @filemtime(dirname(__DIR__) . $f); +require_once dirname(__DIR__) . '/_branding.php'; +$_pname = novacpx_panel_name('NovaCPX'); +?> + + + + + + + + +<?= $_pname ?> — My Hosting + + + + + + + + + +
+
+
+ + + + + + + + + +
+
My Hosting
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + +