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:
2026-06-09 02:56:45 +00:00
parent f75f124725
commit 537d52dafa
16 changed files with 618 additions and 230 deletions
+21 -1
View File
@@ -33,13 +33,24 @@ class Auth {
private function loginBySession(string $sessionId): bool {
$db = DB::getInstance();
$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
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > NOW() AND u.status = 'active'",
[hash('sha256', $sessionId)]
);
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;
return true;
}
@@ -70,6 +81,15 @@ class Auth {
);
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
if (!empty($user['totp_enabled'])) {
if ($totpCode === null) {
+4 -4
View File
@@ -4,7 +4,7 @@ class BackupManager {
private string $backupRoot = '/home/novacpx-backups';
public function __construct() {
$this->db = Database::getInstance()->getPDO();
$this->db = DB::getInstance()->pdo();
if (!is_dir($this->backupRoot)) mkdir($this->backupRoot, 0750, true);
}
@@ -25,14 +25,14 @@ class BackupManager {
try {
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);
if ($rc !== 0) throw new RuntimeException("tar failed: " . implode("\n", $out));
}
if ($type === 'full' || $type === 'database') {
// 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]);
foreach ($dbs->fetchAll(PDO::FETCH_COLUMN) as $dbName) {
$dumpFile = escapeshellarg("{$dir}/{$account['username']}_{$dbName}_{$ts}.sql.gz");
@@ -80,7 +80,7 @@ class BackupManager {
// ── Restore ───────────────────────────────────────────────────────────────
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]);
$backup = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$backup) throw new RuntimeException("Backup not found");
+2 -2
View File
@@ -10,8 +10,8 @@ class DNSManager {
public static function createZone(int $accountId, string $domain): void {
$db = DB::getInstance();
$serial = (int)date('Ymd') * 100 + 1;
$ns1 = self::getSetting('default_nameserver1', 'ns1.localhost');
$ns2 = self::getSetting('default_nameserver2', 'ns2.localhost');
$ns1 = self::getSetting('ns1_hostname', 'ns1.localhost');
$ns2 = self::getSetting('ns2_hostname', 'ns2.localhost');
$email = 'hostmaster.' . $domain;
$ip = self::serverIp();