mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
Admin: global view of all subdomains/parked across accounts; nav items added Reseller: filtered view scoped to their customers' accounts User: create/remove subdomains and parked domains for own account Backend already existed in api/endpoints/domains.php (add-subdomain, add-alias, list, remove actions). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
This commit is contained in:
+51
-17
@@ -1,33 +1,34 @@
|
||||
<?php
|
||||
// NovaCPX Admin Panel — Datacenter/Server Manager
|
||||
// Equivalent to WHM (WebHost Manager)
|
||||
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
|
||||
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="NovaCPX Admin Panel — full-featured hosting control panel for managing servers, accounts, domains, email, databases, and services.">
|
||||
<meta name="keywords" content="hosting control panel, server management, web hosting admin, NovaCPX, domain management, email hosting, database management">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>NovaCPX Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/nova-favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="app" style="display:none">
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small></span>
|
||||
<?= novacpx_logo_html('<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/><path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient><linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient></defs></svg>') ?>
|
||||
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small>
|
||||
<span id="panel-version-badge" style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: NOVACPX_VERSION) ?></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
@@ -61,6 +62,14 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
|
||||
Create Account
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="subdomains" onclick="window.adminSubdomains()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h13"/><path d="M18 15l3 3-3 3"/></svg>
|
||||
Subdomains
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="parked-domains" onclick="window.adminParked()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
Parked Domains
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
@@ -97,10 +106,18 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
FTP Server
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="nginx-proxy">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
Nginx Proxy
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="wordpress">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
WordPress
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="docker">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="8" width="4" height="4" rx="1"/><rect x="8" y="8" width="4" height="4" rx="1"/><rect x="14" y="8" width="4" height="4" rx="1"/><rect x="8" y="2" width="4" height="4" rx="1"/><path d="M2 14c0 4 3 6 10 6s10-2 10-6"/><path d="M20 14c1.5 0 2.5-1 2.5-2.5S21.5 9 20 9h-1"/></svg>
|
||||
Docker
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
@@ -111,7 +128,11 @@
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="firewall">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Firewall / Fail2Ban
|
||||
Firewall (UFW)
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="fail2ban">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
Fail2Ban
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="audit-log">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
@@ -121,6 +142,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1" fill="currentColor"/></svg>
|
||||
2FA Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="sessions">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
||||
Sessions
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
@@ -137,6 +162,14 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"/></svg>
|
||||
Cloudflare
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="server-options">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
Server Options
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="notifications">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
Notifications
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
@@ -161,7 +194,8 @@
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
|
||||
<div class="topbar-title" id="page-title">Dashboard</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="server-ip" class="text-muted text-sm"></span>
|
||||
@@ -206,7 +240,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/nova.js"></script>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/admin.js<?= $_v('/assets/js/admin.js') ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
backups,
|
||||
cloudflare,
|
||||
'server-options': serverOptions,
|
||||
'subdomains': window.adminSubdomains,
|
||||
'parked-domains': window.adminParked,
|
||||
notifications,
|
||||
settings,
|
||||
};
|
||||
@@ -4619,3 +4621,65 @@ ${results.map(z=>`<tr>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
};
|
||||
|
||||
// ══ ADMIN SUBDOMAINS PAGE ═════════════════════════════════════════════════
|
||||
window.adminSubdomains = async function() {
|
||||
Nova.loadPage('subdomains', window._novaPages); document.getElementById('page-title').textContent='All Subdomains'; document.getElementById('page-content').innerHTML=`, 'All Subdomains', `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">All subdomains across all hosting accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="admin-sub-list"><div class="loading">Loading…</div></div></div>`);
|
||||
`;
|
||||
const el = document.getElementById('admin-sub-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
const accts = res.data;
|
||||
let rows = [];
|
||||
for (const acct of accts) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
dr.data.filter(d=>d.type==='subdomain').forEach(d => rows.push({...d, account_username: acct.username, account_domain: acct.domain}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No subdomains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Subdomain</th><th>SSL</th><th>Created</th><th></th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr>
|
||||
<td>${d.account_username}</td>
|
||||
<td><strong>${d.domain}</strong></td>
|
||||
<td>${d.ssl_enabled ? '<span class="badge badge-green">SSL</span>' : '—'}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td>
|
||||
<td></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
// ══ ADMIN PARKED DOMAINS PAGE ═════════════════════════════════════════════
|
||||
window.adminParked = async function() {
|
||||
document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active'));
|
||||
document.querySelector('[data-page="parked-domains"]')?.classList.add('active');
|
||||
document.getElementById('page-title').textContent = 'Parked Domains';
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">All parked/alias domains across all accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="admin-park-list"><div class="loading">Loading…</div></div></div>`);
|
||||
`;
|
||||
const el = document.getElementById('admin-park-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
let rows = [];
|
||||
for (const acct of res.data) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
const main = dr.data.find(d=>d.type==='main');
|
||||
dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d => rows.push({...d, account_username: acct.username, main_domain: main?.domain||acct.domain}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No parked domains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Parked Domain</th><th>Points To</th><th>Created</th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr>
|
||||
<td>${d.account_username}</td>
|
||||
<td><strong>${d.domain}</strong></td>
|
||||
<td style="color:var(--text-muted);font-size:.82rem">${d.main_domain}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
@@ -325,6 +325,11 @@ const rNavGroups = [
|
||||
{ id: 'packages', label: 'Packages',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>' },
|
||||
]},
|
||||
{ id: 'subdomains', label: 'Subdomains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h13"/><path d="M18 15l3 3-3 3"/></svg>' },
|
||||
{ id: 'parked-domains', label: 'Parked Domains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>' },
|
||||
]},
|
||||
{ label: 'DNS', items: [
|
||||
{ id: 'dns', label: 'DNS Zones',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>' },
|
||||
@@ -361,6 +366,8 @@ function renderRNav() {
|
||||
document.getElementById('sidebar-overlay')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
const extras = {'subdomains': window.resellerSubdomains, 'parked-domains': window.resellerParked};
|
||||
if (extras[link.dataset.page]) { extras[link.dataset.page](); _rActivePage = link.dataset.page; return; }
|
||||
resellerNav(link.dataset.page);
|
||||
});
|
||||
});
|
||||
@@ -699,3 +706,54 @@ window.rWlSave = async () => {
|
||||
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
|
||||
r?.success ? 'success' : 'error');
|
||||
};
|
||||
|
||||
// ══ RESELLER SUBDOMAINS PAGE ══════════════════════════════════════════════
|
||||
window.resellerSubdomains = async function() {
|
||||
document.getElementById('page-title').textContent = 'Subdomains';
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Subdomains across your customers' accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="rsub-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const el = document.getElementById('rsub-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
let rows = [];
|
||||
for (const acct of res.data) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
dr.data.filter(d=>d.type==='subdomain').forEach(d=>rows.push({...d,acct_username:acct.username}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No subdomains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Subdomain</th><th>SSL</th><th>Created</th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||||
<td>${d.ssl_enabled?'<span class="badge badge-green">SSL</span>':'—'}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
// ══ RESELLER PARKED DOMAINS PAGE ══════════════════════════════════════════
|
||||
window.resellerParked = async function() {
|
||||
document.getElementById('page-title').textContent = 'Parked Domains';
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Parked domains across your customers' accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="rpark-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const el = document.getElementById('rpark-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
let rows = [];
|
||||
for (const acct of res.data) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
const main = dr.data.find(d=>d.type==='main');
|
||||
dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d=>rows.push({...d,acct_username:acct.username,main_domain:main?.domain||acct.domain}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No parked domains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Parked Domain</th><th>Points To</th><th>Created</th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||||
<td style="color:var(--text-muted);font-size:.82rem">${d.main_domain}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
@@ -937,8 +937,13 @@ const navGroups = [
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>' },
|
||||
]},
|
||||
{ label: 'Hosting', items: [
|
||||
{ id: 'domains', label: 'Domains',
|
||||
{ id: 'domains', label: 'Domains',
|
||||
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>' },
|
||||
{ id: 'subdomains', label: 'Subdomains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h13"/><path d="M18 15l3 3-3 3"/></svg>' },
|
||||
{ id: 'parked-domains', label: 'Parked Domains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>' },
|
||||
{ id: 'email', label: 'Email',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>' },
|
||||
{ id: 'databases', label: 'Databases',
|
||||
@@ -1003,6 +1008,11 @@ window.userNav = (page) => {
|
||||
_activePage = page;
|
||||
renderNav();
|
||||
const allItems = navGroups.flatMap(g => g.items);
|
||||
// Extra pages not in nav groups
|
||||
const extraPages = {
|
||||
'subdomains': userSubdomains,
|
||||
'parked-domains': userParked,
|
||||
};
|
||||
const item = allItems.find(n => n.id === page);
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl && item) titleEl.textContent = item.label;
|
||||
@@ -1275,3 +1285,111 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
renderNav();
|
||||
window.userNav('dashboard');
|
||||
});
|
||||
|
||||
// ══ SUBDOMAINS PAGE ════════════════════════════════════════════════════════
|
||||
async function userSubdomains() {
|
||||
Nova.loadPage('subdomains', 'Subdomains', `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Create subdomains on your hosted domains.</p>
|
||||
<button class="btn btn-primary btn-sm" onclick="addSubdomain()">+ Add Subdomain</button>
|
||||
</div>
|
||||
<div class="card"><div id="subdomains-list"><div class="loading">Loading…</div></div></div>`);
|
||||
loadSubdomainsList();
|
||||
}
|
||||
|
||||
async function loadSubdomainsList() {
|
||||
const el = document.getElementById('subdomains-list');
|
||||
if (!el) return;
|
||||
const res = await Nova.api('domains', 'list');
|
||||
if (!res?.success) { el.innerHTML = '<div class="empty">No domains found</div>'; return; }
|
||||
const rows = res.data.filter(d => d.type === 'subdomain');
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<div class="empty">No subdomains yet. Click <strong>+ Add Subdomain</strong> to create one.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="table"><thead><tr>
|
||||
<th>Subdomain</th><th>Document Root</th><th>SSL</th><th>Created</th><th>Actions</th>
|
||||
</tr></thead><tbody>${rows.map(d => `<tr>
|
||||
<td><strong>${d.domain}</strong></td>
|
||||
<td style="font-size:.78rem;color:var(--text-muted)">${d.document_root||'—'}</td>
|
||||
<td>${d.ssl_enabled ? Nova.badge('SSL','green') : '<span class="text-muted" style="font-size:.78rem">None</span>'}</td>
|
||||
<td style="font-size:.78rem">${d.created_at?.split('T')[0]||d.created_at||''}</td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="removeDomainEntry(${d.id},'${d.domain}')">Remove</button></td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
window.userSubdomains = userSubdomains;
|
||||
|
||||
window.addSubdomain = () => {
|
||||
Nova.modal('Add Subdomain', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Subdomain prefix</label>
|
||||
<input id="sd-prefix" class="form-control" placeholder="e.g. blog">
|
||||
<small class="text-muted">Creates <em>blog.yourdomain.com</em></small>
|
||||
</div>`, `<button class="btn btn-primary" onclick="submitSubdomain()">Create</button>`);
|
||||
};
|
||||
|
||||
window.submitSubdomain = async () => {
|
||||
const sub = document.getElementById('sd-prefix')?.value?.trim();
|
||||
if (!sub) { Nova.toast('Enter a subdomain prefix','error'); return; }
|
||||
const res = await Nova.api('domains', 'add-subdomain', { method: 'POST', body: { subdomain: sub } });
|
||||
if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadSubdomainsList(); }
|
||||
else Nova.toast(res?.message||'Failed','error');
|
||||
};
|
||||
|
||||
window.removeDomainEntry = (id, domain) => {
|
||||
Nova.confirm(`Remove ${domain}? This will delete its vhost and directory.`, async () => {
|
||||
const res = await Nova.api('domains','remove',{method:'POST',body:{id}});
|
||||
if (res?.success) { Nova.toast('Removed','success'); loadSubdomainsList(); loadParkedList(); }
|
||||
else Nova.toast(res?.message||'Failed','error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
// ══ PARKED DOMAINS PAGE ════════════════════════════════════════════════════
|
||||
async function userParked() {
|
||||
Nova.loadPage('parked-domains', 'Parked Domains', `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Parked domains point additional domains to your account's main content.</p>
|
||||
<button class="btn btn-primary btn-sm" onclick="addParked()">+ Park Domain</button>
|
||||
</div>
|
||||
<div class="card"><div id="parked-list"><div class="loading">Loading…</div></div></div>`);
|
||||
loadParkedList();
|
||||
}
|
||||
|
||||
async function loadParkedList() {
|
||||
const el = document.getElementById('parked-list');
|
||||
if (!el) return;
|
||||
const res = await Nova.api('domains','list');
|
||||
if (!res?.success) { el.innerHTML = '<div class="empty">No domains</div>'; return; }
|
||||
const rows = res.data.filter(d => d.type === 'parked' || d.type === 'alias');
|
||||
const main = res.data.find(d => d.type === 'main');
|
||||
if (!rows.length) {
|
||||
el.innerHTML = `<div class="empty">No parked domains yet. Click <strong>+ Park Domain</strong> to add one.</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="table"><thead><tr>
|
||||
<th>Parked Domain</th><th>Points To</th><th>Created</th><th>Actions</th>
|
||||
</tr></thead><tbody>${rows.map(d=>`<tr>
|
||||
<td><strong>${d.domain}</strong></td>
|
||||
<td style="font-size:.78rem;color:var(--text-muted)">${main?.domain||'—'}</td>
|
||||
<td style="font-size:.78rem">${d.created_at?.split('T')[0]||d.created_at||''}</td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="removeDomainEntry(${d.id},'${d.domain}')">Remove</button></td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
window.userParked = userParked;
|
||||
|
||||
window.addParked = () => {
|
||||
Nova.modal('Park a Domain', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Domain name</label>
|
||||
<input id="pd-domain" class="form-control" placeholder="e.g. example.com">
|
||||
<small class="text-muted">This domain will serve the same content as your primary domain.</small>
|
||||
</div>`, `<button class="btn btn-primary" onclick="submitParked()">Park Domain</button>`);
|
||||
};
|
||||
|
||||
window.submitParked = async () => {
|
||||
const domain = document.getElementById('pd-domain')?.value?.trim();
|
||||
if (!domain) { Nova.toast('Enter a domain','error'); return; }
|
||||
const res = await Nova.api('domains','add-alias',{method:'POST',body:{domain}});
|
||||
if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadParkedList(); }
|
||||
else Nova.toast(res?.message||'Failed','error');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user