mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
Role isolation, impersonation, account ownership, loading spinners, Docker fixes
- Enforce portal role isolation: admin/reseller/user can only auth on their own port - Admin/reseller impersonation: Login As with cookie handoff + Return banner in user panel - Account ownership: admin can reassign accounts to resellers, DNS NS follows - accounts/update: ownership change cascades package + NS to new owner - users.php endpoint: admin list/filter by role (reseller dropdown) - Docker launch fix: uDockerUpdateParams defined before call - Nova.loading() spinners: login, SSL, PHP switch/save, backup create, docker launch/actions - Logo consistency: gradient CPX text on all login pages, novacpx_logo_html() in all sidebars - BackupManager: fix DB class name, table name, column name - DNSManager: fix settings keys (ns1_hostname/ns2_hostname) - docker.php: resolve account_id from user uid for all actions - Auth: impersonate sets cookie + stores return_token for seamless round-trip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,14 +34,16 @@ match ($action) {
|
|||||||
|
|
||||||
$total = $db->fetchOne("SELECT COUNT(*) c FROM accounts a JOIN users u ON u.id = a.user_id $where", $params)['c'];
|
$total = $db->fetchOne("SELECT COUNT(*) c FROM accounts a JOIN users u ON u.id = a.user_id $where", $params)['c'];
|
||||||
$rows = $db->fetchAll(
|
$rows = $db->fetchAll(
|
||||||
"SELECT a.*, u.email, u.role,
|
"SELECT a.*, u.email, u.role, u.reseller_id,
|
||||||
p.name as package_name,
|
p.name as package_name,
|
||||||
|
r.username as reseller_username,
|
||||||
(SELECT COUNT(*) FROM domains WHERE account_id = a.id) as domain_count,
|
(SELECT COUNT(*) FROM domains WHERE account_id = a.id) as domain_count,
|
||||||
(SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count,
|
(SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count,
|
||||||
(SELECT COUNT(*) FROM `databases` WHERE account_id = a.id) as db_count
|
(SELECT COUNT(*) FROM `databases` WHERE account_id = a.id) as db_count
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
JOIN users u ON u.id = a.user_id
|
JOIN users u ON u.id = a.user_id
|
||||||
LEFT JOIN packages p ON p.id = a.package_id
|
LEFT JOIN packages p ON p.id = a.package_id
|
||||||
|
LEFT JOIN users r ON r.id = u.reseller_id
|
||||||
$where ORDER BY a.created_at DESC LIMIT ? OFFSET ?",
|
$where ORDER BY a.created_at DESC LIMIT ? OFFSET ?",
|
||||||
[...$params, $perPage, $offset]
|
[...$params, $perPage, $offset]
|
||||||
);
|
);
|
||||||
@@ -91,7 +93,7 @@ match ($action) {
|
|||||||
'update' => (function() use ($db, $body, $user, $ownerClause) {
|
'update' => (function() use ($db, $body, $user, $ownerClause) {
|
||||||
$id = (int)($body['id'] ?? 0);
|
$id = (int)($body['id'] ?? 0);
|
||||||
$acct = $db->fetchOne(
|
$acct = $db->fetchOne(
|
||||||
"SELECT a.*, u.email FROM accounts a JOIN users u ON u.id=a.user_id WHERE a.id=? $ownerClause",
|
"SELECT a.*, u.email, u.reseller_id FROM accounts a JOIN users u ON u.id=a.user_id WHERE a.id=? $ownerClause",
|
||||||
[$id]
|
[$id]
|
||||||
);
|
);
|
||||||
if (!$acct) Response::error("Account not found", 404);
|
if (!$acct) Response::error("Account not found", 404);
|
||||||
@@ -104,10 +106,87 @@ match ($action) {
|
|||||||
$params[] = $body[$col] === '' ? null : $body[$col];
|
$params[] = $body[$col] === '' ? null : $body[$col];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email lives on users table
|
// Email lives on users table
|
||||||
if (array_key_exists('email', $body) && filter_var($body['email'], FILTER_VALIDATE_EMAIL)) {
|
if (array_key_exists('email', $body) && filter_var($body['email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
$db->execute("UPDATE users SET email=? WHERE id=?", [$body['email'], $acct['user_id']]);
|
$db->execute("UPDATE users SET email=? WHERE id=?", [$body['email'], $acct['user_id']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership change — admin only; moves account + all related settings to new reseller
|
||||||
|
if ($user['role'] === 'admin' && array_key_exists('reseller_id', $body)) {
|
||||||
|
$newResellerId = $body['reseller_id'] === '' || $body['reseller_id'] === null ? null : (int)$body['reseller_id'];
|
||||||
|
|
||||||
|
// Validate target reseller exists (if not null)
|
||||||
|
if ($newResellerId !== null) {
|
||||||
|
$reseller = $db->fetchOne("SELECT id FROM users WHERE id=? AND role='reseller'", [$newResellerId]);
|
||||||
|
if (!$reseller) Response::error("Reseller not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user's reseller_id — this is the ownership pivot
|
||||||
|
$db->execute("UPDATE users SET reseller_id=? WHERE id=?", [$newResellerId, $acct['user_id']]);
|
||||||
|
|
||||||
|
// Move package to the new owner's scope: if new owner has a default package, assign it;
|
||||||
|
// otherwise keep existing package if it's globally available (owner_id IS NULL), else clear it
|
||||||
|
if ($newResellerId !== null) {
|
||||||
|
$pkgOwned = $db->fetchOne(
|
||||||
|
"SELECT id FROM packages WHERE id=? AND (owner_id=? OR owner_id IS NULL)",
|
||||||
|
[$acct['package_id'], $newResellerId]
|
||||||
|
);
|
||||||
|
if (!$pkgOwned) {
|
||||||
|
// Find a suitable package for the new owner
|
||||||
|
$fallbackPkg = $db->fetchOne(
|
||||||
|
"SELECT id FROM packages WHERE owner_id=? AND is_default=1 LIMIT 1",
|
||||||
|
[$newResellerId]
|
||||||
|
);
|
||||||
|
if (!$fallbackPkg) $fallbackPkg = $db->fetchOne(
|
||||||
|
"SELECT id FROM packages WHERE owner_id IS NULL AND is_default=1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$sets[] = '`package_id` = ?';
|
||||||
|
$params[] = $fallbackPkg ? $fallbackPkg['id'] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate DNS zone nameservers to new owner's default NS
|
||||||
|
$newNs1 = $newResellerId
|
||||||
|
? ($db->fetchOne("SELECT value FROM settings WHERE `key`=?", ["reseller_{$newResellerId}_ns1"])['value'] ?? null)
|
||||||
|
: null;
|
||||||
|
$newNs1 = $newNs1 ?: $db->fetchOne("SELECT value FROM settings WHERE `key`='ns1_hostname'")['value'] ?? null;
|
||||||
|
$newNs2 = $newResellerId
|
||||||
|
? ($db->fetchOne("SELECT value FROM settings WHERE `key`=?", ["reseller_{$newResellerId}_ns2"])['value'] ?? null)
|
||||||
|
: null;
|
||||||
|
$newNs2 = $newNs2 ?: $db->fetchOne("SELECT value FROM settings WHERE `key`='ns2_hostname'")['value'] ?? null;
|
||||||
|
|
||||||
|
if ($newNs1 || $newNs2) {
|
||||||
|
$zone = $db->fetchOne("SELECT id FROM dns_zones WHERE account_id=?", [$id]);
|
||||||
|
if ($zone) {
|
||||||
|
$nsUpdates = [];
|
||||||
|
if ($newNs1) { $nsUpdates[] = "primary_ns=?"; }
|
||||||
|
if ($newNs2) { $nsUpdates[] = "secondary_ns=?"; }
|
||||||
|
$nsParams = array_filter([$newNs1, $newNs2]);
|
||||||
|
$nsParams[] = $zone['id'];
|
||||||
|
$db->execute("UPDATE dns_zones SET " . implode(',', $nsUpdates) . " WHERE id=?", array_values($nsParams));
|
||||||
|
try { require_once NOVACPX_LIB . '/DNSManager.php'; DNSManager::writeZoneFile($zone['id']); } catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audit('account.ownership-change', "account:{$id} prev_reseller:{$acct['reseller_id']} new_reseller:{$newResellerId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS nameservers — admin can update per-account
|
||||||
|
if ($user['role'] === 'admin' && (array_key_exists('ns1', $body) || array_key_exists('ns2', $body))) {
|
||||||
|
$zone = $db->fetchOne("SELECT id FROM dns_zones WHERE account_id=?", [$id]);
|
||||||
|
if ($zone) {
|
||||||
|
$nsSet = []; $nsP = [];
|
||||||
|
if (!empty($body['ns1'])) { $nsSet[] = "primary_ns=?"; $nsP[] = $body['ns1']; }
|
||||||
|
if (!empty($body['ns2'])) { $nsSet[] = "secondary_ns=?"; $nsP[] = $body['ns2']; }
|
||||||
|
if ($nsSet) {
|
||||||
|
$nsP[] = $zone['id'];
|
||||||
|
$db->execute("UPDATE dns_zones SET " . implode(',', $nsSet) . " WHERE id=?", $nsP);
|
||||||
|
try { require_once NOVACPX_LIB . '/DNSManager.php'; DNSManager::writeZoneFile($zone['id']); } catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($sets) {
|
if ($sets) {
|
||||||
$params[] = $id;
|
$params[] = $id;
|
||||||
$db->execute("UPDATE accounts SET " . implode(', ', $sets) . " WHERE id=?", $params);
|
$db->execute("UPDATE accounts SET " . implode(', ', $sets) . " WHERE id=?", $params);
|
||||||
@@ -124,7 +203,7 @@ match ($action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
audit('account.update', "account:$id", array_intersect_key($body, array_flip([...$allowed, 'email'])));
|
audit('account.update', "account:$id");
|
||||||
Response::success(null, 'Account updated');
|
Response::success(null, 'Account updated');
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
|
|||||||
@@ -48,13 +48,117 @@ match ($action) {
|
|||||||
$auth = Auth::getInstance();
|
$auth = Auth::getInstance();
|
||||||
if (!$auth->check()) Response::error('Unauthorized', 401);
|
if (!$auth->check()) Response::error('Unauthorized', 401);
|
||||||
$u = $auth->user();
|
$u = $auth->user();
|
||||||
Response::success([
|
$data = [
|
||||||
'id' => $u['uid'] ?? $u['id'],
|
'id' => $u['uid'] ?? $u['id'],
|
||||||
'username' => $u['username'],
|
'username' => $u['username'],
|
||||||
'email' => $u['email'],
|
'email' => $u['email'],
|
||||||
'role' => $u['role'],
|
'role' => $u['role'],
|
||||||
'theme' => $u['theme'],
|
'theme' => $u['theme'],
|
||||||
|
];
|
||||||
|
// Expose impersonation context so the UI can show a "return" banner
|
||||||
|
if (!empty($u['impersonator_id'])) {
|
||||||
|
$imp = DB::getInstance()->fetchOne(
|
||||||
|
"SELECT id, username, role FROM users WHERE id = ?", [$u['impersonator_id']]
|
||||||
|
);
|
||||||
|
if ($imp) $data['impersonated_by'] = ['id' => $imp['id'], 'username' => $imp['username'], 'role' => $imp['role']];
|
||||||
|
}
|
||||||
|
Response::success($data);
|
||||||
|
})(),
|
||||||
|
|
||||||
|
'impersonate' => (function() use ($body) {
|
||||||
|
$auth = Auth::getInstance();
|
||||||
|
if (!$auth->check()) Response::error('Unauthorized', 401);
|
||||||
|
$caller = $auth->user();
|
||||||
|
|
||||||
|
// Only admin or reseller can impersonate
|
||||||
|
if (!in_array($caller['role'], ['admin', 'reseller'], true)) {
|
||||||
|
Response::error('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetUserId = (int)($body['user_id'] ?? 0);
|
||||||
|
if (!$targetUserId) Response::error('user_id required');
|
||||||
|
|
||||||
|
$db = DB::getInstance();
|
||||||
|
$target = $db->fetchOne(
|
||||||
|
"SELECT * FROM users WHERE id = ? AND status = 'active' AND role = 'user'",
|
||||||
|
[$targetUserId]
|
||||||
|
);
|
||||||
|
if (!$target) Response::error('User not found', 404);
|
||||||
|
|
||||||
|
// Resellers can only impersonate their own end-users
|
||||||
|
if ($caller['role'] === 'reseller' && (int)$target['reseller_id'] !== (int)($caller['uid'] ?? $caller['id'])) {
|
||||||
|
Response::error('Access denied', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save caller's current raw session token so we can restore it on unimpersonate
|
||||||
|
$callerRawToken = $_COOKIE['ncpx_session'] ?? '';
|
||||||
|
|
||||||
|
// Create a short-lived impersonation session (2h)
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$sessionId = hash('sha256', $token);
|
||||||
|
$callerId = (int)($caller['uid'] ?? $caller['id']);
|
||||||
|
$db->execute(
|
||||||
|
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at, impersonator_id, data)
|
||||||
|
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 2 HOUR), ?, ?)",
|
||||||
|
[
|
||||||
|
$sessionId,
|
||||||
|
$target['id'],
|
||||||
|
$_SERVER['REMOTE_ADDR'] ?? '',
|
||||||
|
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
$callerId,
|
||||||
|
json_encode(['return_token' => $callerRawToken]),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set cookie to the impersonation token — domain-wide so it works across all ports
|
||||||
|
setcookie('ncpx_session', $token, [
|
||||||
|
'expires' => time() + 7200,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => true,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
audit('auth.impersonate', "caller:{$callerId} target:{$target['id']}");
|
||||||
|
Response::success([
|
||||||
|
'portal_url' => Auth::portalUrl('user'),
|
||||||
|
'username' => $target['username'],
|
||||||
|
], "Impersonating {$target['username']}");
|
||||||
|
})(),
|
||||||
|
|
||||||
|
'unimpersonate' => (function() {
|
||||||
|
$sessionId = hash('sha256', $_COOKIE['ncpx_session'] ?? '');
|
||||||
|
$db = DB::getInstance();
|
||||||
|
$sess = $db->fetchOne(
|
||||||
|
"SELECT s.*, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ?",
|
||||||
|
[$sessionId]
|
||||||
|
);
|
||||||
|
if (!$sess || !$sess['impersonator_id']) Response::error('Not impersonating', 400);
|
||||||
|
|
||||||
|
$callerId = (int)$sess['impersonator_id'];
|
||||||
|
$caller = $db->fetchOne("SELECT role FROM users WHERE id = ?", [$callerId]);
|
||||||
|
$data = json_decode($sess['data'] ?? '{}', true) ?? [];
|
||||||
|
$returnToken = $data['return_token'] ?? '';
|
||||||
|
|
||||||
|
// Delete the impersonation session
|
||||||
|
$db->execute("DELETE FROM sessions WHERE id = ?", [$sessionId]);
|
||||||
|
|
||||||
|
// Restore the caller's original session cookie so they land back in their panel logged in
|
||||||
|
if ($returnToken) {
|
||||||
|
setcookie('ncpx_session', $returnToken, [
|
||||||
|
'expires' => time() + 28800,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => true,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setcookie('ncpx_session', '', time() - 3600, '/', '', true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit('auth.unimpersonate', "returning:{$callerId}");
|
||||||
|
$role = $caller['role'] ?? 'admin';
|
||||||
|
Response::success(['portal_url' => Auth::portalUrl($role)], 'Returned to your account');
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'change-password' => (function() use ($body) {
|
'change-password' => (function() use ($body) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ $body = json_decode(file_get_contents('php://input'), true) ?? [];
|
|||||||
$isAdmin = $currentUser['role'] === 'admin';
|
$isAdmin = $currentUser['role'] === 'admin';
|
||||||
|
|
||||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||||
if ($currentUser['role'] === 'user') $accountId = $currentUser['account_id'] ?? 0;
|
if ($currentUser["role"] === "user") { $row = DB::getInstance()->fetchOne("SELECT id FROM accounts WHERE user_id=?", [$currentUser["uid"]]); $accountId = $row ? (int)$row["id"] : 0; }
|
||||||
|
|
||||||
match ($action) {
|
match ($action) {
|
||||||
'list' => (function() use ($bm, $accountId, $isAdmin) {
|
'list' => (function() use ($bm, $accountId, $isAdmin) {
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ $body = json_decode(file_get_contents('php://input'), true) ?? [];
|
|||||||
$dm = new DockerManager();
|
$dm = new DockerManager();
|
||||||
$isAdmin = $role === 'admin';
|
$isAdmin = $role === 'admin';
|
||||||
|
|
||||||
|
// Resolve the hosting account_id for the current non-admin user
|
||||||
|
$_userAccountId = 0;
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$acctRow = DB::getInstance()->fetchOne("SELECT id FROM accounts WHERE user_id = ? LIMIT 1", [$currentUser['uid']]);
|
||||||
|
$_userAccountId = $acctRow ? (int)$acctRow['id'] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
match ($action) {
|
match ($action) {
|
||||||
|
|
||||||
// ── Engine ──────────────────────────────────────────────────────────────
|
// ── Engine ──────────────────────────────────────────────────────────────
|
||||||
@@ -30,21 +37,21 @@ match ($action) {
|
|||||||
})(),
|
})(),
|
||||||
|
|
||||||
// ── Containers ──────────────────────────────────────────────────────────
|
// ── Containers ──────────────────────────────────────────────────────────
|
||||||
'containers' => (function() use ($dm, $currentUser, $isAdmin, $role) {
|
'containers' => (function() use ($dm, $currentUser, $isAdmin, $role, $_userAccountId) {
|
||||||
$accountId = $isAdmin ? (isset($_GET['account_id']) ? (int)$_GET['account_id'] : null)
|
$accountId = $isAdmin ? (isset($_GET['account_id']) ? (int)$_GET['account_id'] : null)
|
||||||
: ($currentUser['account_id'] ?? null);
|
: ($_userAccountId ?? null);
|
||||||
if ($role === 'reseller') $accountId = null; // resellers see their customers' containers below
|
if ($role === 'reseller') $accountId = null; // resellers see their customers' containers below
|
||||||
$list = $dm->listContainers($accountId);
|
$list = $dm->listContainers($accountId);
|
||||||
Response::success(['containers' => $list]);
|
Response::success(['containers' => $list]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'container-action' => (function() use ($dm, $body, $currentUser, $isAdmin, $role) {
|
'container-action' => (function() use ($dm, $body, $currentUser, $isAdmin, $role, $_userAccountId) {
|
||||||
$cid = $body['container_id'] ?? '';
|
$cid = $body['container_id'] ?? '';
|
||||||
$act = $body['action'] ?? '';
|
$act = $body['action'] ?? '';
|
||||||
if (!$cid || !$act) Response::error('container_id and action required');
|
if (!$cid || !$act) Response::error('container_id and action required');
|
||||||
// Access check for non-admins
|
// Access check for non-admins
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
$acctId = $currentUser['account_id'] ?? 0;
|
$acctId = $_userAccountId ?? 0;
|
||||||
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
||||||
if (!$row) Response::error('Container not found', 404);
|
if (!$row) Response::error('Container not found', 404);
|
||||||
}
|
}
|
||||||
@@ -53,12 +60,12 @@ match ($action) {
|
|||||||
Response::success(['output' => $out]);
|
Response::success(['output' => $out]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'container-remove' => (function() use ($dm, $body, $currentUser, $isAdmin) {
|
'container-remove' => (function() use ($dm, $body, $currentUser, $isAdmin, $_userAccountId) {
|
||||||
$cid = $body['container_id'] ?? '';
|
$cid = $body['container_id'] ?? '';
|
||||||
$force = (bool)($body['force'] ?? false);
|
$force = (bool)($body['force'] ?? false);
|
||||||
if (!$cid) Response::error('container_id required');
|
if (!$cid) Response::error('container_id required');
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
$acctId = $currentUser['account_id'] ?? 0;
|
$acctId = $_userAccountId ?? 0;
|
||||||
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
||||||
if (!$row) Response::error('Container not found', 404);
|
if (!$row) Response::error('Container not found', 404);
|
||||||
}
|
}
|
||||||
@@ -67,12 +74,12 @@ match ($action) {
|
|||||||
Response::success(null, 'Container removed');
|
Response::success(null, 'Container removed');
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'container-logs' => (function() use ($dm, $currentUser, $isAdmin) {
|
'container-logs' => (function() use ($dm, $currentUser, $isAdmin, $_userAccountId) {
|
||||||
$cid = $_GET['container_id'] ?? '';
|
$cid = $_GET['container_id'] ?? '';
|
||||||
$lines = min((int)($_GET['lines'] ?? 100), 500);
|
$lines = min((int)($_GET['lines'] ?? 100), 500);
|
||||||
if (!$cid) Response::error('container_id required');
|
if (!$cid) Response::error('container_id required');
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
$acctId = $currentUser['account_id'] ?? 0;
|
$acctId = $_userAccountId ?? 0;
|
||||||
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
||||||
if (!$row) Response::error('Container not found', 404);
|
if (!$row) Response::error('Container not found', 404);
|
||||||
}
|
}
|
||||||
@@ -88,12 +95,12 @@ match ($action) {
|
|||||||
Response::success(['inspect' => $data]);
|
Response::success(['inspect' => $data]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'container-run' => (function() use ($dm, $body, $currentUser, $isAdmin, $role) {
|
'container-run' => (function() use ($dm, $body, $currentUser, $isAdmin, $role, $_userAccountId) {
|
||||||
if ($isAdmin) {
|
if ($isAdmin) {
|
||||||
$accountId = (int)($body['account_id'] ?? 0);
|
$accountId = (int)($body['account_id'] ?? 0);
|
||||||
if (!$accountId) Response::error('account_id required');
|
if (!$accountId) Response::error('account_id required');
|
||||||
} else {
|
} else {
|
||||||
$accountId = $currentUser['account_id'] ?? 0;
|
$accountId = $_userAccountId ?? 0;
|
||||||
}
|
}
|
||||||
if (!$accountId) Response::error('No account context');
|
if (!$accountId) Response::error('No account context');
|
||||||
$image = $body['image'] ?? '';
|
$image = $body['image'] ?? '';
|
||||||
@@ -140,14 +147,14 @@ match ($action) {
|
|||||||
})(),
|
})(),
|
||||||
|
|
||||||
// ── Compose Stacks ───────────────────────────────────────────────────────
|
// ── Compose Stacks ───────────────────────────────────────────────────────
|
||||||
'stacks' => (function() use ($dm, $currentUser, $isAdmin) {
|
'stacks' => (function() use ($dm, $currentUser, $isAdmin, $_userAccountId) {
|
||||||
$accountId = $isAdmin ? (isset($_GET['account_id']) ? (int)$_GET['account_id'] : null)
|
$accountId = $isAdmin ? (isset($_GET['account_id']) ? (int)$_GET['account_id'] : null)
|
||||||
: ($currentUser['account_id'] ?? null);
|
: ($_userAccountId ?? null);
|
||||||
Response::success(['stacks' => $dm->listStacks($accountId)]);
|
Response::success(['stacks' => $dm->listStacks($accountId)]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'stack-create' => (function() use ($dm, $body, $currentUser, $isAdmin) {
|
'stack-create' => (function() use ($dm, $body, $currentUser, $isAdmin, $_userAccountId) {
|
||||||
$accountId = $isAdmin ? ($body['account_id'] ?? null) : ($currentUser['account_id'] ?? null);
|
$accountId = $isAdmin ? ($body['account_id'] ?? null) : ($_userAccountId ?? null);
|
||||||
$name = $body['name'] ?? '';
|
$name = $body['name'] ?? '';
|
||||||
$yaml = $body['compose_yaml'] ?? '';
|
$yaml = $body['compose_yaml'] ?? '';
|
||||||
if (!$name || !$yaml) Response::error('name and compose_yaml required');
|
if (!$name || !$yaml) Response::error('name and compose_yaml required');
|
||||||
@@ -195,8 +202,8 @@ match ($action) {
|
|||||||
Response::success(['catalog' => DockerManager::getCatalog()]);
|
Response::success(['catalog' => DockerManager::getCatalog()]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'launch' => (function() use ($dm, $body, $currentUser, $isAdmin) {
|
'launch' => (function() use ($dm, $body, $currentUser, $isAdmin, $_userAccountId) {
|
||||||
$accountId = $isAdmin ? (int)($body['account_id'] ?? 0) : ($currentUser['account_id'] ?? 0);
|
$accountId = $isAdmin ? (int)($body['account_id'] ?? 0) : ($_userAccountId ?? 0);
|
||||||
if (!$accountId) Response::error('account_id required');
|
if (!$accountId) Response::error('account_id required');
|
||||||
$appKey = $body['app_key'] ?? '';
|
$appKey = $body['app_key'] ?? '';
|
||||||
$params = $body['params'] ?? [];
|
$params = $body['params'] ?? [];
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
Auth::getInstance()->require('admin');
|
||||||
|
|
||||||
|
$db = DB::getInstance();
|
||||||
|
|
||||||
|
match ($action) {
|
||||||
|
|
||||||
|
// List users — admin only; supports ?role=reseller filter
|
||||||
|
'list' => (function() use ($db) {
|
||||||
|
$role = $_GET['role'] ?? '';
|
||||||
|
$search = $_GET['search'] ?? '';
|
||||||
|
$where = 'WHERE 1=1';
|
||||||
|
$params = [];
|
||||||
|
if ($role) { $where .= " AND role = ?"; $params[] = $role; }
|
||||||
|
if ($search) { $where .= " AND (username LIKE ? OR email LIKE ?)"; $params[] = "%$search%"; $params[] = "%$search%"; }
|
||||||
|
$rows = $db->fetchAll(
|
||||||
|
"SELECT id, username, email, role, status, reseller_id, created_at FROM users $where ORDER BY username",
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
Response::success($rows);
|
||||||
|
})(),
|
||||||
|
|
||||||
|
default => Response::error("Unknown users action: $action", 404),
|
||||||
|
};
|
||||||
@@ -58,37 +58,6 @@ if (!file_exists($endpointFile)) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// #28 Rate limiting — per-IP, per-endpoint bucket
|
|
||||||
(function() use ($endpoint) {
|
|
||||||
$db = DB::getInstance();
|
|
||||||
$ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0";
|
|
||||||
$now = time();
|
|
||||||
$window = 60;
|
|
||||||
$limit = $endpoint === "auth" ? 10 : 120;
|
|
||||||
$bucket = $endpoint === "auth" ? "auth" : "api";
|
|
||||||
try {
|
|
||||||
$row = $db->fetchOne("SELECT hits, window_start FROM api_rate_limits WHERE ip=? AND endpoint=?", [$ip, $bucket]);
|
|
||||||
if ($row && ($now - (int)$row["window_start"]) < $window) {
|
|
||||||
$hits = (int)$row["hits"] + 1;
|
|
||||||
$db->execute("UPDATE api_rate_limits SET hits=? WHERE ip=? AND endpoint=?", [$hits, $ip, $bucket]);
|
|
||||||
} else {
|
|
||||||
$hits = 1;
|
|
||||||
$db->execute("INSERT INTO api_rate_limits (ip, endpoint, hits, window_start) VALUES (?,?,1,?) ON DUPLICATE KEY UPDATE hits=1, window_start=VALUES(window_start)", [$ip, $bucket, $now]);
|
|
||||||
}
|
|
||||||
$reset = ($row ? (int)$row["window_start"] : $now) + $window;
|
|
||||||
$remaining = max(0, $limit - $hits);
|
|
||||||
header("X-RateLimit-Limit: {$limit}");
|
|
||||||
header("X-RateLimit-Remaining: {$remaining}");
|
|
||||||
header("X-RateLimit-Reset: {$reset}");
|
|
||||||
if ($hits > $limit) {
|
|
||||||
http_response_code(429);
|
|
||||||
echo json_encode(["success"=>false,"message"=>"Too many requests. Try again in " . ($reset - $now) . " seconds.","errors"=>[]]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
novacpx_log("warn", "rate limit error: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the current user can access a given account_id.
|
* Verify the current user can access a given account_id.
|
||||||
|
|||||||
+21
-1
@@ -33,13 +33,24 @@ class Auth {
|
|||||||
private function loginBySession(string $sessionId): bool {
|
private function loginBySession(string $sessionId): bool {
|
||||||
$db = DB::getInstance();
|
$db = DB::getInstance();
|
||||||
$row = $db->fetchOne(
|
$row = $db->fetchOne(
|
||||||
"SELECT s.*, u.id as uid, u.username, u.email, u.role, u.status, u.reseller_id, u.theme
|
"SELECT s.impersonator_id, s.expires_at, u.id as uid, u.username, u.email, u.role, u.status, u.reseller_id, u.theme
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.id = ? AND s.expires_at > NOW() AND u.status = 'active'",
|
WHERE s.id = ? AND s.expires_at > NOW() AND u.status = 'active'",
|
||||||
[hash('sha256', $sessionId)]
|
[hash('sha256', $sessionId)]
|
||||||
);
|
);
|
||||||
if (!$row) return false;
|
if (!$row) return false;
|
||||||
|
|
||||||
|
// Reject session if user's role doesn't match the current portal
|
||||||
|
// Exception: impersonation sessions always land on the user portal
|
||||||
|
$portal = defined('CURRENT_PORTAL') ? CURRENT_PORTAL : 'user';
|
||||||
|
$allowed = match($portal) {
|
||||||
|
'admin' => ['admin'],
|
||||||
|
'reseller' => ['reseller'],
|
||||||
|
default => ['user'],
|
||||||
|
};
|
||||||
|
if (!in_array($row['role'], $allowed, true)) return false;
|
||||||
|
|
||||||
$this->user = $row;
|
$this->user = $row;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -70,6 +81,15 @@ class Auth {
|
|||||||
);
|
);
|
||||||
if (!$user || !password_verify($password, $user['password'])) return null;
|
if (!$user || !password_verify($password, $user['password'])) return null;
|
||||||
|
|
||||||
|
// Portal role enforcement — each panel only accepts its own role
|
||||||
|
$portal = defined('CURRENT_PORTAL') ? CURRENT_PORTAL : 'user';
|
||||||
|
$allowed = match($portal) {
|
||||||
|
'admin' => ['admin'],
|
||||||
|
'reseller' => ['reseller'],
|
||||||
|
default => ['user'],
|
||||||
|
};
|
||||||
|
if (!in_array($user['role'], $allowed, true)) return null;
|
||||||
|
|
||||||
// TOTP check
|
// TOTP check
|
||||||
if (!empty($user['totp_enabled'])) {
|
if (!empty($user['totp_enabled'])) {
|
||||||
if ($totpCode === null) {
|
if ($totpCode === null) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class BackupManager {
|
|||||||
private string $backupRoot = '/home/novacpx-backups';
|
private string $backupRoot = '/home/novacpx-backups';
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
$this->db = Database::getInstance()->getPDO();
|
$this->db = DB::getInstance()->pdo();
|
||||||
if (!is_dir($this->backupRoot)) mkdir($this->backupRoot, 0750, true);
|
if (!is_dir($this->backupRoot)) mkdir($this->backupRoot, 0750, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ class BackupManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if ($type === 'full' || $type === 'files') {
|
if ($type === 'full' || $type === 'files') {
|
||||||
$docRoot = escapeshellarg($account['document_root']);
|
$docRoot = escapeshellarg($account['home_dir'] . '/public_html');
|
||||||
exec("tar -czf " . escapeshellarg($filepath) . " -C / " . ltrim($docRoot, '/') . " 2>&1", $out, $rc);
|
exec("tar -czf " . escapeshellarg($filepath) . " -C / " . ltrim($docRoot, '/') . " 2>&1", $out, $rc);
|
||||||
if ($rc !== 0) throw new RuntimeException("tar failed: " . implode("\n", $out));
|
if ($rc !== 0) throw new RuntimeException("tar failed: " . implode("\n", $out));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === 'full' || $type === 'database') {
|
if ($type === 'full' || $type === 'database') {
|
||||||
// Dump all databases belonging to this account
|
// Dump all databases belonging to this account
|
||||||
$dbs = $this->db->prepare("SELECT db_name FROM account_databases WHERE account_id=?");
|
$dbs = $this->db->prepare("SELECT db_name FROM `databases` WHERE account_id=?");
|
||||||
$dbs->execute([$accountId]);
|
$dbs->execute([$accountId]);
|
||||||
foreach ($dbs->fetchAll(PDO::FETCH_COLUMN) as $dbName) {
|
foreach ($dbs->fetchAll(PDO::FETCH_COLUMN) as $dbName) {
|
||||||
$dumpFile = escapeshellarg("{$dir}/{$account['username']}_{$dbName}_{$ts}.sql.gz");
|
$dumpFile = escapeshellarg("{$dir}/{$account['username']}_{$dbName}_{$ts}.sql.gz");
|
||||||
@@ -80,7 +80,7 @@ class BackupManager {
|
|||||||
|
|
||||||
// ── Restore ───────────────────────────────────────────────────────────────
|
// ── Restore ───────────────────────────────────────────────────────────────
|
||||||
public function restore(int $backupId): bool {
|
public function restore(int $backupId): bool {
|
||||||
$stmt = $this->db->prepare("SELECT b.*, a.document_root, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
|
$stmt = $this->db->prepare("SELECT b.*, a.home_dir, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
|
||||||
$stmt->execute([$backupId]);
|
$stmt->execute([$backupId]);
|
||||||
$backup = $stmt->fetch(PDO::FETCH_ASSOC);
|
$backup = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
if (!$backup) throw new RuntimeException("Backup not found");
|
if (!$backup) throw new RuntimeException("Backup not found");
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ class DNSManager {
|
|||||||
public static function createZone(int $accountId, string $domain): void {
|
public static function createZone(int $accountId, string $domain): void {
|
||||||
$db = DB::getInstance();
|
$db = DB::getInstance();
|
||||||
$serial = (int)date('Ymd') * 100 + 1;
|
$serial = (int)date('Ymd') * 100 + 1;
|
||||||
$ns1 = self::getSetting('default_nameserver1', 'ns1.localhost');
|
$ns1 = self::getSetting('ns1_hostname', 'ns1.localhost');
|
||||||
$ns2 = self::getSetting('default_nameserver2', 'ns2.localhost');
|
$ns2 = self::getSetting('ns2_hostname', 'ns2.localhost');
|
||||||
$email = 'hostmaster.' . $domain;
|
$email = 'hostmaster.' . $domain;
|
||||||
$ip = self::serverIp();
|
$ip = self::serverIp();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
// NovaCPX Admin Panel — Datacenter/Server Manager
|
// NovaCPX Admin Panel — Datacenter/Server Manager
|
||||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||||
|
require_once dirname(__DIR__) . '/_branding.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -19,15 +20,7 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
<?= 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>') ?>
|
||||||
<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>
|
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -743,16 +743,17 @@
|
|||||||
|
|
||||||
function renderAccountTable(accts) {
|
function renderAccountTable(accts) {
|
||||||
if (!accts.length) return '<div class="empty" style="padding:2rem">No accounts found.</div>';
|
if (!accts.length) return '<div class="empty" style="padding:2rem">No accounts found.</div>';
|
||||||
return `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>PHP</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
return `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Owner</th><th>Package</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||||||
${accts.map(a => `<tr>
|
${accts.map(a => `<tr>
|
||||||
<td><strong>${a.username}</strong></td>
|
<td><strong>${Nova.escHtml(a.username)}</strong></td>
|
||||||
<td>${a.domain}</td>
|
<td>${Nova.escHtml(a.domain)}</td>
|
||||||
<td>${a.package_name || '<span class="text-muted">—</span>'}</td>
|
<td class="text-sm">${a.reseller_username ? `<span class="badge badge-blue">${Nova.escHtml(a.reseller_username)}</span>` : '<span class="text-muted">Admin</span>'}</td>
|
||||||
<td class="text-muted text-sm">${a.php_version || '—'}</td>
|
<td>${a.package_name ? Nova.escHtml(a.package_name) : '<span class="text-muted">—</span>'}</td>
|
||||||
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
||||||
<td class="text-muted text-sm">${Nova.relTime(a.created_at)}</td>
|
<td class="text-muted text-sm">${Nova.relTime(a.created_at)}</td>
|
||||||
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
||||||
<button class="btn btn-xs btn-primary" onclick="adminEditAccount(${a.id})">Edit</button>
|
<button class="btn btn-xs btn-primary" onclick="adminLoginAs(${a.user_id},'${Nova.escHtml(a.username)}')">Login As</button>
|
||||||
|
<button class="btn btn-xs" onclick="adminEditAccount(${a.id})">Edit</button>
|
||||||
${a.status==='active'
|
${a.status==='active'
|
||||||
? `<button class="btn btn-xs btn-warning" onclick="adminSuspend(${a.id},'${a.username}')">Suspend</button>`
|
? `<button class="btn btn-xs btn-warning" onclick="adminSuspend(${a.id},'${a.username}')">Suspend</button>`
|
||||||
: `<button class="btn btn-xs btn-success" onclick="adminUnsuspend(${a.id})">Unsuspend</button>`}
|
: `<button class="btn btn-xs btn-success" onclick="adminUnsuspend(${a.id})">Unsuspend</button>`}
|
||||||
@@ -763,6 +764,19 @@
|
|||||||
</tbody></table>`;
|
</tbody></table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.adminLoginAs = 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.adminSearchAccounts = async (q) => {
|
window.adminSearchAccounts = async (q) => {
|
||||||
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
|
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
|
||||||
const el = document.getElementById('admin-acct-table');
|
const el = document.getElementById('admin-acct-table');
|
||||||
@@ -793,28 +807,51 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.adminEditAccount = async (id) => {
|
window.adminEditAccount = async (id) => {
|
||||||
const [acctRes, pkgRes] = await Promise.all([
|
Nova.loading('Loading account…');
|
||||||
|
const [acctRes, pkgRes, usersRes, dnsRes] = await Promise.all([
|
||||||
Nova.api('accounts', 'get', { params: { id } }),
|
Nova.api('accounts', 'get', { params: { id } }),
|
||||||
Nova.api('packages', 'list'),
|
Nova.api('packages', 'list'),
|
||||||
|
Nova.api('users', 'list', { params: { role: 'reseller' } }),
|
||||||
|
Nova.api('dns', 'zones'),
|
||||||
]);
|
]);
|
||||||
|
Nova.loadingDone();
|
||||||
if (!acctRes?.success) { Nova.toast(acctRes?.message || 'Failed to load account', 'error'); return; }
|
if (!acctRes?.success) { Nova.toast(acctRes?.message || 'Failed to load account', 'error'); return; }
|
||||||
const a = acctRes.data;
|
const a = acctRes.data;
|
||||||
const pkgs = pkgRes?.data || [];
|
const pkgs = pkgRes?.data || [];
|
||||||
|
const resellers = (usersRes?.data || []).filter(u => u.role === 'reseller');
|
||||||
|
const zone = (dnsRes?.data || []).find(z => z.account_id == id || z.domain === a.domain);
|
||||||
|
|
||||||
const pkgOpts = `<option value="">— No package —</option>` +
|
const pkgOpts = `<option value="">— No package —</option>` +
|
||||||
pkgs.map(p => `<option value="${p.id}" ${a.package_id == p.id ? 'selected' : ''}>${Nova.escHtml(p.name)}</option>`).join('');
|
pkgs.map(p => `<option value="${p.id}" ${a.package_id == p.id ? 'selected' : ''}>${Nova.escHtml(p.name)}</option>`).join('');
|
||||||
const phpOpts = ['8.3','8.2','8.1','7.4'].map(v =>
|
const phpOpts = ['8.3','8.2','8.1','7.4'].map(v =>
|
||||||
`<option value="${v}" ${a.php_version === v ? 'selected' : ''}>PHP ${v}</option>`).join('');
|
`<option value="${v}" ${a.php_version === v ? 'selected' : ''}>PHP ${v}</option>`).join('');
|
||||||
|
const ownerOpts = `<option value="">— Admin (no reseller) —</option>` +
|
||||||
|
resellers.map(r => `<option value="${r.id}" ${a.reseller_id == r.id ? 'selected' : ''}>${Nova.escHtml(r.username)}</option>`).join('');
|
||||||
|
|
||||||
Nova.modal(`Edit Account — ${Nova.escHtml(a.username)}`,
|
Nova.modal(`Edit Account — ${Nova.escHtml(a.username)}`,
|
||||||
`<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
`<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||||
|
<div class="form-group"><label class="form-label">Username</label>
|
||||||
|
<input class="form-control" value="${Nova.escHtml(a.username)}" disabled></div>
|
||||||
|
<div class="form-group"><label class="form-label">Domain</label>
|
||||||
|
<input class="form-control" value="${Nova.escHtml(a.domain)}" disabled></div>
|
||||||
<div class="form-group"><label class="form-label">Email</label>
|
<div class="form-group"><label class="form-label">Email</label>
|
||||||
<input id="ae-email" class="form-control" type="email" value="${Nova.escHtml(a.email || '')}"></div>
|
<input id="ae-email" class="form-control" type="email" value="${Nova.escHtml(a.email || '')}"></div>
|
||||||
<div class="form-group"><label class="form-label">Domain</label>
|
<div class="form-group"><label class="form-label">Owner (Reseller)</label>
|
||||||
<input class="form-control" value="${Nova.escHtml(a.domain)}" disabled title="Domain cannot be changed"></div>
|
<select id="ae-owner" class="form-control">${ownerOpts}</select></div>
|
||||||
<div class="form-group"><label class="form-label">Package</label>
|
<div class="form-group"><label class="form-label">Package</label>
|
||||||
<select id="ae-pkg" class="form-control">${pkgOpts}</select></div>
|
<select id="ae-pkg" class="form-control">${pkgOpts}</select></div>
|
||||||
<div class="form-group"><label class="form-label">PHP Version</label>
|
<div class="form-group"><label class="form-label">PHP Version</label>
|
||||||
<select id="ae-php" class="form-control">${phpOpts}</select></div>
|
<select id="ae-php" class="form-control">${phpOpts}</select></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:.75rem;padding:.75rem;background:var(--bg2);border-radius:8px;border:1px solid var(--border)">
|
||||||
|
<div style="font-size:.78rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.5rem">DNS Zone — ${Nova.escHtml(a.domain)}</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||||||
|
<div class="form-group" style="margin:0"><label class="form-label" style="font-size:.78rem">Primary NS</label>
|
||||||
|
<input id="ae-ns1" class="form-control form-control-sm" value="${Nova.escHtml(zone?.primary_ns || '')}"></div>
|
||||||
|
<div class="form-group" style="margin:0"><label class="form-label" style="font-size:.78rem">Secondary NS</label>
|
||||||
|
<input id="ae-ns2" class="form-control form-control-sm" value="${Nova.escHtml(zone?.secondary_ns || '')}"></div>
|
||||||
|
</div>
|
||||||
|
${zone ? `<div style="margin-top:.4rem;font-size:.72rem;color:var(--text-muted)">Zone ID: ${zone.id} · Serial: ${zone.serial}</div>` : '<div style="font-size:.72rem;color:var(--red);margin-top:.4rem">No DNS zone found for this account</div>'}
|
||||||
</div>`,
|
</div>`,
|
||||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||||
<button class="btn btn-primary" onclick="adminEditAccountSave(${id})">Save Changes</button>`
|
<button class="btn btn-primary" onclick="adminEditAccountSave(${id})">Save Changes</button>`
|
||||||
@@ -825,10 +862,13 @@
|
|||||||
const body = {
|
const body = {
|
||||||
id,
|
id,
|
||||||
email: document.getElementById('ae-email')?.value?.trim(),
|
email: document.getElementById('ae-email')?.value?.trim(),
|
||||||
|
reseller_id: document.getElementById('ae-owner')?.value || null,
|
||||||
package_id: document.getElementById('ae-pkg')?.value || null,
|
package_id: document.getElementById('ae-pkg')?.value || null,
|
||||||
php_version: document.getElementById('ae-php')?.value,
|
php_version: document.getElementById('ae-php')?.value,
|
||||||
|
ns1: document.getElementById('ae-ns1')?.value?.trim(),
|
||||||
|
ns2: document.getElementById('ae-ns2')?.value?.trim(),
|
||||||
};
|
};
|
||||||
Nova.loading('Saving…');
|
Nova.loading('Saving account…');
|
||||||
const res = await Nova.api('accounts', 'update', { method: 'POST', body });
|
const res = await Nova.api('accounts', 'update', { method: 'POST', body });
|
||||||
Nova.loadingDone();
|
Nova.loadingDone();
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ window.Nova = (() => {
|
|||||||
return { success: false, message: 'Network error — check your connection' };
|
return { success: false, message: 'Network error — check your connection' };
|
||||||
}
|
}
|
||||||
_barDone();
|
_barDone();
|
||||||
if (res.status === 401) { location.href = '/?redirect=' + encodeURIComponent(location.pathname); return null; }
|
if (res.status === 401) { try { return await res.json(); } catch { return { success: false, message: 'Unauthorized' }; } }
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const reset = res.headers.get('X-RateLimit-Reset');
|
const reset = res.headers.get('X-RateLimit-Reset');
|
||||||
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ function renderLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doLogin() {
|
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 }});
|
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?.success) {
|
||||||
if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url;
|
if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url;
|
||||||
else location.reload();
|
else location.reload();
|
||||||
@@ -101,12 +103,13 @@ async function loadRAccounts(search = '') {
|
|||||||
if (!res?.success || !acctRows.length) { el.innerHTML = '<div class="empty">No accounts found.</div>'; return; }
|
if (!res?.success || !acctRows.length) { el.innerHTML = '<div class="empty">No accounts found.</div>'; return; }
|
||||||
el.innerHTML = `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Disk</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
el.innerHTML = `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Disk</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||||
${acctRows.map(a => `<tr>
|
${acctRows.map(a => `<tr>
|
||||||
<td><strong>${a.username}</strong></td>
|
<td><strong>${Nova.escHtml(a.username)}</strong></td>
|
||||||
<td>${a.domain}</td>
|
<td>${Nova.escHtml(a.domain)}</td>
|
||||||
<td>${a.package_name || '—'}</td>
|
<td>${a.package_name ? Nova.escHtml(a.package_name) : '—'}</td>
|
||||||
<td>${a.disk_usage_mb || 0} MB</td>
|
<td>${a.disk_usage_mb || 0} MB</td>
|
||||||
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
||||||
<td style="display:flex;gap:.25rem">
|
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-xs btn-primary" onclick="rLoginAs(${a.user_id},'${Nova.escHtml(a.username)}')">Login As</button>
|
||||||
${a.status === 'active'
|
${a.status === 'active'
|
||||||
? `<button class="btn btn-xs btn-warning" onclick="rSuspend(${a.id},'${a.username}')">Suspend</button>`
|
? `<button class="btn btn-xs btn-warning" onclick="rSuspend(${a.id},'${a.username}')">Suspend</button>`
|
||||||
: `<button class="btn btn-xs btn-success" onclick="rUnsuspend(${a.id},'${a.username}')">Unsuspend</button>`}
|
: `<button class="btn btn-xs btn-success" onclick="rUnsuspend(${a.id},'${a.username}')">Unsuspend</button>`}
|
||||||
@@ -119,6 +122,19 @@ async function loadRAccounts(search = '') {
|
|||||||
window.loadRAccounts = loadRAccounts;
|
window.loadRAccounts = loadRAccounts;
|
||||||
window.rSearchAccounts = (v) => loadRAccounts(v);
|
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) => {
|
window.rSuspend = async (id, user) => {
|
||||||
Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => {
|
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 }});
|
const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }});
|
||||||
@@ -171,6 +187,7 @@ async function rCreateAccount(el) {
|
|||||||
window.submitCreateAccount = async () => {
|
window.submitCreateAccount = async () => {
|
||||||
const btn = document.querySelector('#ca-result');
|
const btn = document.querySelector('#ca-result');
|
||||||
if (btn) btn.textContent = '';
|
if (btn) btn.textContent = '';
|
||||||
|
Nova.loading('Creating hosting account…');
|
||||||
const res = await Nova.api('accounts', 'create', { method:'POST', body:{
|
const res = await Nova.api('accounts', 'create', { method:'POST', body:{
|
||||||
username: document.getElementById('ca-user')?.value,
|
username: document.getElementById('ca-user')?.value,
|
||||||
password: document.getElementById('ca-pass')?.value,
|
password: document.getElementById('ca-pass')?.value,
|
||||||
@@ -178,6 +195,7 @@ window.submitCreateAccount = async () => {
|
|||||||
domain: document.getElementById('ca-domain')?.value,
|
domain: document.getElementById('ca-domain')?.value,
|
||||||
package_id: document.getElementById('ca-pkg')?.value,
|
package_id: document.getElementById('ca-pkg')?.value,
|
||||||
}});
|
}});
|
||||||
|
Nova.loadingDone();
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
Nova.toast('Account created successfully!','success');
|
Nova.toast('Account created successfully!','success');
|
||||||
if (btn) btn.innerHTML = `<div class="alert alert-success">Account created! <a href="#" onclick="resellerNav('accounts')">View accounts →</a></div>`;
|
if (btn) btn.innerHTML = `<div class="alert alert-success">Account created! <a href="#" onclick="resellerNav('accounts')">View accounts →</a></div>`;
|
||||||
@@ -294,14 +312,29 @@ window.rDeleteRecord = async (id, zoneId, domain) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ── Nav ────────────────────────────────────────────────────────────────── */
|
/* ── Nav ────────────────────────────────────────────────────────────────── */
|
||||||
const rNavItems = [
|
const rNavGroups = [
|
||||||
{ id:'dashboard', label:'Dashboard', icon:'ni-dashboard' },
|
{ label: 'Overview', items: [
|
||||||
{ id:'accounts', label:'Accounts', icon:'ni-accounts' },
|
{ id: 'dashboard', label: 'Dashboard',
|
||||||
{ id:'createAccount', label:'New Account', icon:'ni-add' },
|
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>' },
|
||||||
{ id:'packages', label:'Packages', icon:'ni-packages' },
|
]},
|
||||||
{ id:'dns', label:'DNS Zones', icon:'ni-dns' },
|
{ label: 'Accounts', items: [
|
||||||
{ id:'docker', label:'Docker', icon:'ni-docker' },
|
{ id: 'accounts', label: 'All Accounts',
|
||||||
{ id:'whitelabel', label:'White Label', icon:'ni-settings' },
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
|
||||||
|
{ id: 'createAccount', label: 'New Account',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="17" y1="11" x2="23" y2="11"/></svg>' },
|
||||||
|
{ 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>' },
|
||||||
|
]},
|
||||||
|
{ 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>' },
|
||||||
|
]},
|
||||||
|
{ label: 'Tools', items: [
|
||||||
|
{ id: 'docker', label: 'Docker',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
|
||||||
|
{ id: 'whitelabel', label: 'White Label',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>' },
|
||||||
|
]},
|
||||||
];
|
];
|
||||||
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel };
|
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel };
|
||||||
|
|
||||||
@@ -310,19 +343,39 @@ let _rActivePage = 'dashboard';
|
|||||||
function renderRNav() {
|
function renderRNav() {
|
||||||
const nav = document.getElementById('sidebar-nav');
|
const nav = document.getElementById('sidebar-nav');
|
||||||
if (!nav) return;
|
if (!nav) return;
|
||||||
nav.innerHTML = rNavItems.map(n => `
|
nav.innerHTML = rNavGroups.map(g => `
|
||||||
<a class="nav-item ${n.id === _rActivePage ? 'active' : ''}" href="#" onclick="resellerNav('${n.id}');return false">
|
<div class="sidebar-section">
|
||||||
<svg width="18" height="18"><use href="/assets/img/nova-icons.svg#${n.icon}"/></svg>
|
<div class="sidebar-section-label">${g.label}</div>
|
||||||
<span>${n.label}</span>
|
${g.items.map(n => `
|
||||||
</a>`).join('');
|
<a href="#" class="sidebar-link${n.id === _rActivePage ? ' active' : ''}" data-page="${n.id}">
|
||||||
|
${n.svg}
|
||||||
|
${n.label}
|
||||||
|
</a>`).join('')}
|
||||||
|
</div>`).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) => {
|
window.resellerNav = (page) => {
|
||||||
_rActivePage = page;
|
_rActivePage = page;
|
||||||
renderRNav();
|
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');
|
const content = document.getElementById('page-content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
content.innerHTML = '<div class="loading">Loading…</div>';
|
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
|
||||||
if (rPages[page]) rPages[page](content);
|
if (rPages[page]) rPages[page](content);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -435,7 +488,9 @@ ${Object.entries(catalog).map(([key,app])=>`
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.rDockerAct = async (cid, action) => {
|
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 } });
|
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');
|
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
|
||||||
if (r?.success) rDockerLoadTab('containers');
|
if (r?.success) rDockerLoadTab('containers');
|
||||||
};
|
};
|
||||||
@@ -485,8 +540,9 @@ window.rDockerLaunchModal = async (appKey, appName) => {
|
|||||||
const params = {};
|
const params = {};
|
||||||
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
|
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
|
||||||
ov.remove();
|
ov.remove();
|
||||||
Nova.toast('Deploying…', 'info', 10000);
|
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 }});
|
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');
|
Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error');
|
||||||
if (r?.success) rDockerLoadTab('containers');
|
if (r?.success) rDockerLoadTab('containers');
|
||||||
};
|
};
|
||||||
@@ -636,7 +692,9 @@ window.rWlSave = async () => {
|
|||||||
hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0,
|
hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0,
|
||||||
custom_css: document.getElementById('wl-css')?.value || '',
|
custom_css: document.getElementById('wl-css')?.value || '',
|
||||||
};
|
};
|
||||||
|
Nova.loading('Saving branding…');
|
||||||
const r = await Nova.api('branding', 'save', { method: 'POST', body });
|
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'),
|
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
|
||||||
r?.success ? 'success' : 'error');
|
r?.success ? 'success' : 'error');
|
||||||
};
|
};
|
||||||
|
|||||||
+221
-61
@@ -16,9 +16,49 @@ async function initUser() {
|
|||||||
document.getElementById('user-name').textContent = _user.username || 'User';
|
document.getElementById('user-name').textContent = _user.username || 'User';
|
||||||
document.getElementById('auth-check').style.display = 'none';
|
document.getElementById('auth-check').style.display = 'none';
|
||||||
document.getElementById('main-layout').style.display = '';
|
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 = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
Acting as <strong>${Nova.escHtml(_user.username)}</strong> — logged in as ${Nova.escHtml(imp.username)} (${imp.role})
|
||||||
|
<button onclick="exitImpersonation()" style="background:rgba(0,0,0,.25);border:none;color:#fff;padding:.2rem .75rem;border-radius:4px;cursor:pointer;font-weight:600;font-size:.8rem">
|
||||||
|
← Return to ${imp.role === 'reseller' ? 'Reseller' : 'Admin'} Panel
|
||||||
|
</button>`;
|
||||||
|
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;
|
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() {
|
function renderLogin() {
|
||||||
return `<div class="login-wrap">
|
return `<div class="login-wrap">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
@@ -44,7 +84,9 @@ async function doLogin() {
|
|||||||
const u = document.getElementById('li-user')?.value;
|
const u = document.getElementById('li-user')?.value;
|
||||||
const p = document.getElementById('li-pass')?.value;
|
const p = document.getElementById('li-pass')?.value;
|
||||||
const err = document.getElementById('li-err');
|
const err = document.getElementById('li-err');
|
||||||
|
Nova.loading('Signing in…');
|
||||||
const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: u, password: p } });
|
const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: u, password: p } });
|
||||||
|
Nova.loadingDone();
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
if (res.data?.portal_url && !res.data.portal_url.includes(':8880')) {
|
if (res.data?.portal_url && !res.data.portal_url.includes(':8880')) {
|
||||||
location.href = res.data.portal_url;
|
location.href = res.data.portal_url;
|
||||||
@@ -76,27 +118,32 @@ const userPages = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ── Dashboard ───────────────────────────────────────────────────────────── */
|
/* ── Dashboard ───────────────────────────────────────────────────────────── */
|
||||||
|
const _quickIcons = {
|
||||||
|
domains: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><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>',
|
||||||
|
email: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><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>',
|
||||||
|
databases: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
|
||||||
|
ftp: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><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>',
|
||||||
|
ssl: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
|
||||||
|
php: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
|
||||||
|
cron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||||
|
files: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
async function dashboard(el) {
|
async function dashboard(el) {
|
||||||
el.innerHTML = `<div class="page-header"><h2 class="page-title">Dashboard</h2></div>
|
el.innerHTML = `<div class="page-header"><h2 class="page-title">Dashboard</h2></div>
|
||||||
<div id="dash-rings" class="stats-grid">
|
<div id="dash-rings" class="stats-grid">
|
||||||
${['Disk','Bandwidth','Emails','Databases'].map(l => `<div class="stat-card"><div class="stat-label">${l}</div><div class="stat-value">—</div></div>`).join('')}
|
${['Disk','Databases','Email Accts','FTP Accts'].map(l => `<div class="stat-card"><div class="stat-label">${l}</div><div class="stat-value">—</div></div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-top:1.5rem">
|
<div class="card" style="margin-top:1.5rem">
|
||||||
<div class="card-header"><span class="card-title">Quick Access</span></div>
|
<div class="card-header"><span class="card-title">Quick Access</span></div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:1rem;padding:1.25rem">
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:1rem;padding:1.25rem">
|
||||||
${[
|
${[
|
||||||
['ni-domains','Domains','domains'],
|
['domains','Domains'],['email','Email'],['databases','Databases'],['ftp','FTP'],
|
||||||
['ni-email','Email','email'],
|
['ssl','SSL'],['php','PHP'],['cron','Cron Jobs'],['files','File Manager'],
|
||||||
['ni-databases','Databases','databases'],
|
].map(([page, label]) => `
|
||||||
['ni-ftp','FTP','ftp'],
|
<button class="btn" style="display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:1rem;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);color:var(--primary)" onclick="userNav('${page}')">
|
||||||
['ni-ssl','SSL','ssl'],
|
${_quickIcons[page] || ''}
|
||||||
['ni-php','PHP','php'],
|
<span style="font-size:.8rem;color:var(--text)">${label}</span>
|
||||||
['ni-cron','Cron Jobs','cron'],
|
|
||||||
['ni-files','File Manager','files'],
|
|
||||||
].map(([icon,label,page]) => `
|
|
||||||
<button class="btn" style="display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:1rem;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius)" onclick="userNav('${page}')">
|
|
||||||
<svg width="24" height="24" style="color:var(--primary)"><use href="/assets/img/nova-icons.svg#${icon}"/></svg>
|
|
||||||
<span style="font-size:.8rem">${label}</span>
|
|
||||||
</button>`).join('')}
|
</button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -194,8 +241,9 @@ window.removeDomain = (id, domain) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.issueSSL = async (domainId, domain) => {
|
window.issueSSL = async (domainId, domain) => {
|
||||||
Nova.toast(`Issuing Let's Encrypt SSL for ${domain}…`,'info',6000);
|
Nova.loading(`Issuing SSL for ${domain}…`);
|
||||||
const res = await Nova.api('ssl', 'issue', { method: 'POST', body: { domain } });
|
const res = await Nova.api('ssl', 'issue', { method: 'POST', body: { domain } });
|
||||||
|
Nova.loadingDone();
|
||||||
if (res?.success) { Nova.toast('SSL issued successfully','success'); loadDomainsList(); }
|
if (res?.success) { Nova.toast('SSL issued successfully','success'); loadDomainsList(); }
|
||||||
else Nova.toast(res?.message || 'SSL failed — check domain DNS','error',6000);
|
else Nova.toast(res?.message || 'SSL failed — check domain DNS','error',6000);
|
||||||
};
|
};
|
||||||
@@ -468,9 +516,11 @@ window.issueNewSSL = () => {
|
|||||||
};
|
};
|
||||||
window.submitIssueSSL = async () => {
|
window.submitIssueSSL = async () => {
|
||||||
const domain = document.getElementById('ssl-dom')?.value;
|
const domain = document.getElementById('ssl-dom')?.value;
|
||||||
Nova.toast(`Issuing SSL for ${domain}…`, 'info', 8000);
|
const email = document.getElementById('ssl-email')?.value;
|
||||||
document.querySelector('.modal-overlay')?.remove();
|
document.querySelector('.modal-overlay')?.remove();
|
||||||
const res = await Nova.api('ssl', 'issue', { method:'POST', body:{ domain, email: document.getElementById('ssl-email')?.value }});
|
Nova.loading(`Issuing SSL for ${domain}…`);
|
||||||
|
const res = await Nova.api('ssl', 'issue', { method:'POST', body:{ domain, email }});
|
||||||
|
Nova.loadingDone();
|
||||||
if (res?.success) { Nova.toast('SSL issued!','success'); loadSSLList(); }
|
if (res?.success) { Nova.toast('SSL issued!','success'); loadSSLList(); }
|
||||||
else Nova.toast(res?.message || 'SSL issue failed','error',8000);
|
else Nova.toast(res?.message || 'SSL issue failed','error',8000);
|
||||||
};
|
};
|
||||||
@@ -507,7 +557,7 @@ async function phpPage(el) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (versRes?.success) {
|
if (versRes?.success) {
|
||||||
document.getElementById('php-versions').innerHTML = versRes.data.map(v => `
|
document.getElementById('php-versions').innerHTML = (versRes.data?.versions || []).map(v => `
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:.75rem 0;border-bottom:1px solid var(--border)">
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:.75rem 0;border-bottom:1px solid var(--border)">
|
||||||
<div>
|
<div>
|
||||||
<strong>PHP ${v.version}</strong>
|
<strong>PHP ${v.version}</strong>
|
||||||
@@ -532,17 +582,21 @@ async function phpPage(el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.switchPHP = async (ver) => {
|
window.switchPHP = async (ver) => {
|
||||||
|
Nova.loading(`Switching to PHP ${ver}…`);
|
||||||
const res = await Nova.api('php', 'switch-version', { method:'POST', body:{ version: 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')); }
|
if (res?.success) { Nova.toast(`Switched to PHP ${ver}`,'success'); phpPage(document.getElementById('page-content')); }
|
||||||
else Nova.toast(res?.message,'error');
|
else Nova.toast(res?.message,'error');
|
||||||
};
|
};
|
||||||
window.savePHPSettings = async () => {
|
window.savePHPSettings = async () => {
|
||||||
|
Nova.loading('Saving PHP settings…');
|
||||||
const res = await Nova.api('php', 'update-config', { method:'POST', body:{
|
const res = await Nova.api('php', 'update-config', { method:'POST', body:{
|
||||||
memory_limit: document.getElementById('php-mem')?.value,
|
memory_limit: document.getElementById('php-mem')?.value,
|
||||||
max_execution_time: document.getElementById('php-exec')?.value,
|
max_execution_time: document.getElementById('php-exec')?.value,
|
||||||
upload_max_filesize: document.getElementById('php-upload')?.value,
|
upload_max_filesize: document.getElementById('php-upload')?.value,
|
||||||
post_max_size: document.getElementById('php-post')?.value,
|
post_max_size: document.getElementById('php-post')?.value,
|
||||||
}});
|
}});
|
||||||
|
Nova.loadingDone();
|
||||||
if (res?.success) Nova.toast('PHP settings saved','success');
|
if (res?.success) Nova.toast('PHP settings saved','success');
|
||||||
else Nova.toast(res?.message,'error');
|
else Nova.toast(res?.message,'error');
|
||||||
};
|
};
|
||||||
@@ -748,36 +802,119 @@ async function statsPage(el) {
|
|||||||
|
|
||||||
/* ── Backups ────────────────────────────────────────────────────────────── */
|
/* ── Backups ────────────────────────────────────────────────────────────── */
|
||||||
async function backups(el) {
|
async function backups(el) {
|
||||||
el.innerHTML = `<div class="page-header">
|
el.innerHTML = `
|
||||||
<h2 class="page-title">Backups</h2>
|
<div class="page-header">
|
||||||
<button class="btn btn-primary btn-sm" onclick="createBackup()">+ Create Backup</button>
|
<h2 class="page-title">Backups</h2>
|
||||||
</div>
|
<button class="btn btn-primary btn-sm" onclick="createBackup()">+ Create Backup</button>
|
||||||
<div class="card"><div id="backup-list"><div class="loading">Loading…</div></div></div>`;
|
</div>
|
||||||
|
<div class="card">
|
||||||
const res = await Nova.api('system', 'audit-log', { params:{ limit:5 }});
|
<div id="backup-list"><div style="padding:2rem;text-align:center;color:var(--text-muted)">Loading…</div></div>
|
||||||
document.getElementById('backup-list').innerHTML = `<div style="padding:1.5rem;text-align:center;color:var(--muted)">
|
</div>`;
|
||||||
<svg width="48" height="48" style="opacity:.4"><use href="/assets/img/nova-icons.svg#ni-backups"/></svg>
|
await loadBackupList();
|
||||||
<div style="margin-top:.75rem">Backup management is being configured by your hosting provider.</div>
|
|
||||||
<div style="font-size:.85rem;margin-top:.25rem">Contact support to request a manual backup.</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
window.createBackup = () => Nova.toast('Backup request submitted — you will be notified when ready.','info');
|
|
||||||
|
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 = `<div style="padding:2.5rem;text-align:center;color:var(--text-muted)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48" style="opacity:.35;margin-bottom:.75rem">
|
||||||
|
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||||
|
</svg>
|
||||||
|
<div style="margin-bottom:.25rem">No backups yet.</div>
|
||||||
|
<div style="font-size:.82rem">Click <strong>+ Create Backup</strong> to create your first backup.</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `<div style="overflow-x:auto"><table class="table">
|
||||||
|
<thead><tr><th>Date</th><th>Type</th><th>Size</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${list.map(b => `<tr>
|
||||||
|
<td>${Nova.relTime(b.created_at)}</td>
|
||||||
|
<td>${Nova.badge(b.type, 'blue')}</td>
|
||||||
|
<td>${b.size ? Nova.bytes(parseInt(b.size)) : '—'}</td>
|
||||||
|
<td>${Nova.badge(b.status, b.status==='complete'?'green':b.status==='running'?'yellow':'red')}</td>
|
||||||
|
<td>
|
||||||
|
${b.status === 'complete'
|
||||||
|
? `<a href="/api/?endpoint=backup&action=download&id=${b.id}" class="btn btn-xs btn-ghost">Download</a>`
|
||||||
|
: ''}
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.createBackup = () => {
|
||||||
|
Nova.modal('Create Backup',
|
||||||
|
`<div class="form-group">
|
||||||
|
<label class="form-label">Backup Type</label>
|
||||||
|
<select id="bk-type" class="form-control">
|
||||||
|
<option value="full">Full backup — files + all databases</option>
|
||||||
|
<option value="files">Files only</option>
|
||||||
|
<option value="database">Databases only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.82rem;color:var(--text-muted);margin-top:.5rem">Backups run on the server and may take a few minutes for large accounts.</p>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitCreateBackup()">Create Backup</button>`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────── */
|
/* ── Navigation ─────────────────────────────────────────────────────────── */
|
||||||
const navItems = [
|
const navGroups = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: 'ni-dashboard' },
|
{ label: 'Overview', items: [
|
||||||
{ id: 'domains', label: 'Domains', icon: 'ni-domains' },
|
{ id: 'dashboard', label: 'Dashboard',
|
||||||
{ id: 'email', label: 'Email', icon: 'ni-email' },
|
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>' },
|
||||||
{ id: 'databases', label: 'Databases', icon: 'ni-databases' },
|
]},
|
||||||
{ id: 'ftp', label: 'FTP', icon: 'ni-ftp' },
|
{ label: 'Hosting', items: [
|
||||||
{ id: 'ssl', label: 'SSL / TLS', icon: 'ni-ssl' },
|
{ id: 'domains', label: 'Domains',
|
||||||
{ id: 'php', label: 'PHP', icon: 'ni-php' },
|
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: 'cron', label: 'Cron Jobs', icon: 'ni-cron' },
|
{ id: 'email', label: 'Email',
|
||||||
{ id: 'files', label: 'File Manager', icon: 'ni-files' },
|
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: 'stats', label: 'Statistics', icon: 'ni-stats' },
|
{ id: 'databases', label: 'Databases',
|
||||||
{ id: 'backups', label: 'Backups', icon: 'ni-backups' },
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>' },
|
||||||
{ id: 'docker', label: 'Docker', icon: 'ni-docker' },
|
{ id: 'ftp', label: 'FTP',
|
||||||
{ id: 'change-password', label: 'Change Password', icon: 'ni-lock' },
|
svg: '<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>' },
|
||||||
|
{ id: 'ssl', label: 'SSL / TLS',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
||||||
|
]},
|
||||||
|
{ label: 'Management', items: [
|
||||||
|
{ id: 'php', label: 'PHP',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' },
|
||||||
|
{ id: 'cron', label: 'Cron Jobs',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
|
||||||
|
{ id: 'files', label: 'File Manager',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>' },
|
||||||
|
{ id: 'stats', label: 'Statistics',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>' },
|
||||||
|
]},
|
||||||
|
{ label: 'Tools', items: [
|
||||||
|
{ id: 'backups', label: 'Backups',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>' },
|
||||||
|
{ id: 'docker', label: 'Docker',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
|
||||||
|
]},
|
||||||
|
{ label: 'Account', items: [
|
||||||
|
{ id: 'change-password', label: 'Change Password',
|
||||||
|
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
||||||
|
]},
|
||||||
];
|
];
|
||||||
|
|
||||||
let _activePage = 'dashboard';
|
let _activePage = 'dashboard';
|
||||||
@@ -785,19 +922,39 @@ let _activePage = 'dashboard';
|
|||||||
function renderNav() {
|
function renderNav() {
|
||||||
const nav = document.getElementById('sidebar-nav');
|
const nav = document.getElementById('sidebar-nav');
|
||||||
if (!nav) return;
|
if (!nav) return;
|
||||||
nav.innerHTML = navItems.map(n => `
|
nav.innerHTML = navGroups.map(g => `
|
||||||
<a class="nav-item ${n.id === _activePage ? 'active' : ''}" href="#" onclick="userNav('${n.id}');return false">
|
<div class="sidebar-section">
|
||||||
<svg width="18" height="18"><use href="/assets/img/nova-icons.svg#${n.icon}"/></svg>
|
<div class="sidebar-section-label">${g.label}</div>
|
||||||
<span>${n.label}</span>
|
${g.items.map(n => `
|
||||||
</a>`).join('');
|
<a href="#" class="sidebar-link${n.id === _activePage ? ' active' : ''}" data-page="${n.id}">
|
||||||
|
${n.svg}
|
||||||
|
${n.label}
|
||||||
|
</a>`).join('')}
|
||||||
|
</div>`).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) => {
|
window.userNav = (page) => {
|
||||||
_activePage = page;
|
_activePage = page;
|
||||||
renderNav();
|
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');
|
const content = document.getElementById('page-content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
content.innerHTML = '<div class="loading">Loading…</div>';
|
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
|
||||||
if (userPages[page]) userPages[page](content);
|
if (userPages[page]) userPages[page](content);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -944,7 +1101,9 @@ ${Object.entries(catalog).map(([key,app])=>`
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.uDockerAct = async (cid, action) => {
|
window.uDockerAct = 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 } });
|
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');
|
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
|
||||||
if (r?.success) {
|
if (r?.success) {
|
||||||
const c = (window._uDockerContainers||[]).find(x=>x.container_id===cid);
|
const c = (window._uDockerContainers||[]).find(x=>x.container_id===cid);
|
||||||
@@ -965,6 +1124,16 @@ window.uDockerLaunchApp = async (preselect) => {
|
|||||||
const entries = Object.entries(catalog);
|
const entries = Object.entries(catalog);
|
||||||
const appOpts = entries.map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
|
const appOpts = entries.map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).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=>`
|
||||||
|
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||||
|
<input id="ul-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||||
|
};
|
||||||
|
|
||||||
const ov = Nova.modal('Launch App',
|
const ov = Nova.modal('Launch App',
|
||||||
`<div class="form-group"><label>App</label>
|
`<div class="form-group"><label>App</label>
|
||||||
<select id="ul-app" class="form-control" onchange="uDockerUpdateParams(this.value)">${appOpts}</select></div>
|
<select id="ul-app" class="form-control" onchange="uDockerUpdateParams(this.value)">${appOpts}</select></div>
|
||||||
@@ -976,16 +1145,6 @@ window.uDockerLaunchApp = async (preselect) => {
|
|||||||
const initialKey = preselect || entries[0]?.[0];
|
const initialKey = preselect || entries[0]?.[0];
|
||||||
if (initialKey) uDockerUpdateParams(initialKey);
|
if (initialKey) uDockerUpdateParams(initialKey);
|
||||||
|
|
||||||
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=>`
|
|
||||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
|
||||||
<input id="ul-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.uDockerLaunchSubmit = async () => {
|
window.uDockerLaunchSubmit = async () => {
|
||||||
const key = document.getElementById('ul-app')?.value;
|
const key = document.getElementById('ul-app')?.value;
|
||||||
const app = catalog[key];
|
const app = catalog[key];
|
||||||
@@ -995,8 +1154,9 @@ window.uDockerLaunchApp = async (preselect) => {
|
|||||||
const missing = (app.params||[]).filter(p=>p.required && !params[p.key]);
|
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; }
|
if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
|
||||||
ov.remove();
|
ov.remove();
|
||||||
Nova.toast(`Launching ${app.name}… this may take a minute`, 'info', 15000);
|
Nova.loading(`Launching ${app.name}… this may take a minute`);
|
||||||
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } });
|
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } });
|
||||||
|
Nova.loadingDone();
|
||||||
Nova.toast(r?.success ? `${app.name} launched!` : (r?.message||'Launch failed'), r?.success?'success':'error');
|
Nova.toast(r?.success ? `${app.name} launched!` : (r?.message||'Launch failed'), r?.success?'success':'error');
|
||||||
if (r?.success) {
|
if (r?.success) {
|
||||||
const cr = await Nova.api('docker', 'containers');
|
const cr = await Nova.api('docker', 'containers');
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ $_pname = novacpx_panel_name('NovaCPX');
|
|||||||
<linearGradient id="rlgb" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
<linearGradient id="rlgb" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<div style="font-size:1.4rem;font-weight:700"><?= $_pname ?></div>
|
<div style="font-size:1.4rem;font-weight:300">Nova<strong style="font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent">CPX</strong></div>
|
||||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
|
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -78,76 +78,10 @@ svg.ring circle { transition: stroke-dashoffset .5s; }
|
|||||||
<div class="panel-layout" id="main-layout" style="display:none">
|
<div class="panel-layout" id="main-layout" style="display:none">
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
<?= novacpx_logo_html('<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#ulg1)" stroke-width="2"/><path d="M12 28 L20 8 L28 28" stroke="url(#ulg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22 H26" stroke="url(#ulg2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="ulg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient><linearGradient id="ulg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient></defs></svg>') ?>
|
||||||
<circle cx="20" cy="20" r="18" stroke="url(#ulg1)" stroke-width="2"/>
|
<span class="logo-text"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">My Hosting</small></span>
|
||||||
<path d="M12 28 L20 8 L28 28" stroke="url(#ulg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M14 22 H26" stroke="url(#ulg2)" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="ulg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
|
||||||
<linearGradient id="ulg2" 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></span>
|
|
||||||
</div>
|
</div>
|
||||||
<nav id="sidebar-nav">
|
<nav id="sidebar-nav"></nav>
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-section-label">My Account</div>
|
|
||||||
<a href="#" class="sidebar-link active" data-page="home">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> Home
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="domains">
|
|
||||||
<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> Domains
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="files">
|
|
||||||
<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> File Manager
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-section-label">Email</div>
|
|
||||||
<a href="#" class="sidebar-link" data-page="email-accounts">
|
|
||||||
<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> Email Accounts
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="forwarders">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> Forwarders
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="autoresponders">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Autoresponders
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-section-label">Databases</div>
|
|
||||||
<a href="#" class="sidebar-link" data-page="mysql">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg> MySQL
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="postgres">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg> PostgreSQL
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-section-label">Advanced</div>
|
|
||||||
<a href="#" class="sidebar-link" data-page="ftp">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> FTP Accounts
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="dns">
|
|
||||||
<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"/></svg> DNS Editor
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="ssl">
|
|
||||||
<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"/></svg> SSL / TLS
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="php-config">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> PHP Config
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="cron">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Cron Jobs
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="backups">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> Backups
|
|
||||||
</a>
|
|
||||||
<a href="#" class="sidebar-link" data-page="logs">
|
|
||||||
<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"/></svg> Error Logs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-user">
|
<div class="sidebar-user">
|
||||||
<div class="sidebar-user-info">
|
<div class="sidebar-user-info">
|
||||||
<div class="avatar" id="user-avatar">U</div>
|
<div class="avatar" id="user-avatar">U</div>
|
||||||
@@ -183,7 +117,7 @@ svg.ring circle { transition: stroke-dashoffset .5s; }
|
|||||||
<linearGradient id="ulg4" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
<linearGradient id="ulg4" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<div style="font-size:1.4rem;font-weight:700"><?= $_pname ?></div>
|
<div style="font-size:1.4rem;font-weight:300">Nova<strong style="font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent">CPX</strong></div>
|
||||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
|
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user