mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
fix: reseller creation and management in admin panel
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 `
|
||||
<div class="card">
|
||||
@@ -1175,14 +1175,18 @@
|
||||
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
|
||||
</div>
|
||||
<div id="reseller-table">
|
||||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Accounts</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.map(r => `<tr>
|
||||
<td>${r.username}</td><td>${r.email||'—'}</td>
|
||||
<td>${r.account_count||0}</td>
|
||||
<td>${Nova.badge(r.status,r.status==='active'?'green':'red')}</td>
|
||||
<td><strong>${Nova.escHtml(r.username)}</strong></td>
|
||||
<td>${Nova.escHtml(r.email||'—')}</td>
|
||||
<td>${Nova.badge(r.status, r.status==='active'?'green':'red')}</td>
|
||||
<td class="text-sm text-muted">${r.created_at ? r.created_at.slice(0,10) : '—'}</td>
|
||||
<td style="display:flex;gap:.25rem">
|
||||
<button class="btn btn-xs" onclick="adminChangePass(${r.id},'${r.username}')">Passwd</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="adminSuspend(${r.id},'${r.username}')">Suspend</button>
|
||||
<button class="btn btn-xs" onclick="adminResellerPasswd(${r.id},'${Nova.escHtml(r.username)}')">Passwd</button>
|
||||
${r.status === 'active'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="adminResellerSuspend(${r.id},'${Nova.escHtml(r.username)}')">Suspend</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="adminResellerUnsuspend(${r.id})">Unsuspend</button>`}
|
||||
<button class="btn btn-xs btn-danger" onclick="adminResellerDelete(${r.id},'${Nova.escHtml(r.username)}')">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
@@ -1193,10 +1197,51 @@
|
||||
|
||||
window.adminAddReseller = () => {
|
||||
Nova.modal('Create Reseller Account', `
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>`,
|
||||
`<button class="btn btn-primary" onclick="Nova.api('auth','register',{method:'POST',body:{username:document.getElementById('ar-user').value,password:document.getElementById('ar-pass').value,email:document.getElementById('ar-email').value,role:'reseller'}}).then(r=>{if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}else Nova.toast(r?.message,'error');})">Create</button>`);
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control" autocomplete="off"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||
`<button class="btn btn-primary" onclick="
|
||||
Nova.api('users','create',{method:'POST',body:{
|
||||
username:document.getElementById('ar-user').value,
|
||||
email:document.getElementById('ar-email').value,
|
||||
password:document.getElementById('ar-pass').value,
|
||||
role:'reseller'
|
||||
}}).then(r=>{
|
||||
if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
})">Create</button>`);
|
||||
};
|
||||
|
||||
window.adminResellerPasswd = (id, user) => {
|
||||
Nova.modal(`Change Password — ${user}`,
|
||||
`<div class="form-group"><label class="form-label">New Password</label><input id="arp-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||
`<button class="btn btn-primary" onclick="
|
||||
Nova.api('users','change-password',{method:'POST',body:{id:${id},password:document.getElementById('arp-pass').value}}).then(r=>{
|
||||
if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
})">Update</button>`);
|
||||
};
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user