From a5b2a677089c5e98d653f1f8e33d6ff6e299a02b Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 10 Jun 2026 14:44:51 +0000 Subject: [PATCH] fix: reseller creation and management in admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin.js was calling auth/register (action does not exist) — changed to users/create - Reseller list was fetching from accounts/list which is for hosting accounts; fixed to users/list?role=reseller - Replaced shared adminSuspend/adminChangePass (account-scoped) with dedicated adminResellerSuspend/Unsuspend/Passwd/Delete functions that operate on the users table - Added users endpoint actions: create, suspend, unsuspend, change-password, delete — all admin-only, operating on user rows rather than hosting account rows - Reseller delete disowns their accounts (sets reseller_id=NULL) rather than cascading delete Co-Authored-By: Claude Sonnet 4.6 --- panel/api/endpoints/users.php | 75 +++++++++++++++++++++++++++++++++ panel/public/assets/js/admin.js | 67 ++++++++++++++++++++++++----- 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/panel/api/endpoints/users.php b/panel/api/endpoints/users.php index 50b4832..4a81ed8 100644 --- a/panel/api/endpoints/users.php +++ b/panel/api/endpoints/users.php @@ -20,5 +20,80 @@ match ($action) { Response::success($rows); })(), + // Create a panel user (reseller or admin) — no hosting account provisioned + 'create' => (function() use ($db, $body) { + $username = trim($body['username'] ?? ''); + $password = $body['password'] ?? ''; + $email = trim($body['email'] ?? ''); + $role = $body['role'] ?? 'reseller'; + + if (!$username || !$password || !$email) Response::error('username, password and email are required'); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Invalid email address'); + if (!in_array($role, ['reseller', 'admin'], true)) Response::error('role must be reseller or admin'); + if (strlen($password) < 8) Response::error('Password must be at least 8 characters'); + if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $username)) Response::error('Username may only contain letters, numbers, _ - .'); + + $exists = $db->fetchOne("SELECT id FROM users WHERE username=? OR email=?", [$username, $email]); + if ($exists) Response::error('Username or email already in use'); + + $uid = (int)$db->insert( + "INSERT INTO users (username, password, email, role, status, created_at) VALUES (?,?,?,?,?,datetime('now'))", + [$username, password_hash($password, PASSWORD_BCRYPT), $email, $role, 'active'] + ); + audit("user.create.{$role}", "user:{$username}"); + Response::success(['id' => $uid, 'username' => $username, 'email' => $email, 'role' => $role], ucfirst($role) . ' created'); + })(), + + // Suspend a panel user (sets status=suspended on the users row) + 'suspend' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + $u = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]); + if (!$u) Response::error('User not found', 404); + if ($u['role'] === 'admin') Response::error('Cannot suspend admin users'); + $db->execute("UPDATE users SET status='suspended' WHERE id=?", [$id]); + audit('user.suspend', "user:{$u['username']}"); + Response::success(null, 'User suspended'); + })(), + + // Unsuspend a panel user + 'unsuspend' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + $u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]); + if (!$u) Response::error('User not found', 404); + $db->execute("UPDATE users SET status='active' WHERE id=?", [$id]); + audit('user.unsuspend', "user:{$u['username']}"); + Response::success(null, 'User unsuspended'); + })(), + + // Change password for a panel user by user id + 'change-password' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + $pass = $body['password'] ?? ''; + if (!$id) Response::error('id required'); + if (strlen($pass) < 8) Response::error('Password must be at least 8 characters'); + $u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]); + if (!$u) Response::error('User not found', 404); + $db->execute("UPDATE users SET password=? WHERE id=?", [password_hash($pass, PASSWORD_BCRYPT), $id]); + audit('user.change-password', "user:{$u['username']}"); + Response::success(null, 'Password updated'); + })(), + + // Delete a panel user (admin or reseller — not a hosting account user) + 'delete' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + $user = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]); + if (!$user) Response::error('User not found', 404); + if ($user['role'] === 'admin') Response::error('Cannot delete admin users'); + // Disown accounts under this reseller rather than deleting them + $db->execute("UPDATE users SET reseller_id=NULL WHERE reseller_id=?", [$id]); + $db->execute("UPDATE accounts SET reseller_id=NULL WHERE reseller_id=?", [$id]); + $db->execute("DELETE FROM users WHERE id=?", [$id]); + audit('user.delete', "user:{$user['username']}"); + Response::success(null, 'User deleted'); + })(), + default => Response::error("Unknown users action: $action", 404), }; diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 73edc98..3cd46b4 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -1166,7 +1166,7 @@ // ── Resellers ────────────────────────────────────────────────────────────── async function resellers() { - const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }}); + const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }}); const rows = res?.data || []; return `
@@ -1175,14 +1175,18 @@
- ${rows.length ? ` + ${rows.length ? `
UsernameEmailAccountsStatusActions
${rows.map(r => ` - - - + + + + `).join('')}
UsernameEmailStatusCreatedActions
${r.username}${r.email||'—'}${r.account_count||0}${Nova.badge(r.status,r.status==='active'?'green':'red')}${Nova.escHtml(r.username)}${Nova.escHtml(r.email||'—')}${Nova.badge(r.status, r.status==='active'?'green':'red')}${r.created_at ? r.created_at.slice(0,10) : '—'} - - + + ${r.status === 'active' + ? `` + : ``} +
` @@ -1193,10 +1197,51 @@ window.adminAddReseller = () => { Nova.modal('Create Reseller Account', ` -
-
-
`, - ``); +
+
+
`, + ``); + }; + + window.adminResellerPasswd = (id, user) => { + Nova.modal(`Change Password — ${user}`, + `
`, + ``); + }; + + window.adminResellerSuspend = (id, user) => { + Nova.confirm(`Suspend reseller ${user}?`, async () => { + const r = await Nova.api('users','suspend',{method:'POST',body:{id}}); + if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); } + else Nova.toast(r?.message||'Error','error'); + }); + }; + + window.adminResellerUnsuspend = async (id) => { + const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}}); + if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); } + else Nova.toast(r?.message||'Error','error'); + }; + + window.adminResellerDelete = (id, user) => { + Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => { + const r = await Nova.api('users','delete',{method:'POST',body:{id}}); + if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); } + else Nova.toast(r?.message||'Error','error'); + }, true); }; // ── Packages ───────────────────────────────────────────────────────────────