From 33c36ffc6591d11714cccf31ac40baede26a3a33 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 8 Jun 2026 03:51:45 +0000 Subject: [PATCH] Add #18 reseller white-label branding + #24 audit log UI with filters #18: reseller_branding table (migration 008). branding.php endpoint: get/save/ upload-logo/delete-logo/resellers. _branding.php server-side helper injects CSS vars (--primary, --accent), custom CSS, favicon, and panel name into of reseller + user portals at page-load time (no flash of unbranded content). NOVACPX_BRANDING JS global carries panel_name/support_email/ support_url/hide_powered_by for runtime use. Reseller panel gets a new "White Label" sidebar page with logo upload, color pickers with live preview, support contact fields, powered-by toggle, and custom CSS textarea. #24: audit-log backend now accepts user/action/date_from/date_to filter params. auditLog() JS rebuilt: filter bar at top, paginated table, expandable detail rows (click row to show JSON detail), total entry count, page buttons. Co-Authored-By: Claude Sonnet 4.6 --- db/migrations/008_branding_audit.sql | 16 +++ panel/api/endpoints/branding.php | 113 ++++++++++++++++++++ panel/api/endpoints/system.php | 26 ++++- panel/public/_branding.php | 80 ++++++++++++++ panel/public/assets/js/admin.js | 121 +++++++++++++++++---- panel/public/assets/js/reseller.js | 152 ++++++++++++++++++++++++++- panel/public/reseller/index.php | 29 ++--- panel/public/user/index.php | 17 ++- 8 files changed, 512 insertions(+), 42 deletions(-) create mode 100644 db/migrations/008_branding_audit.sql create mode 100644 panel/api/endpoints/branding.php create mode 100644 panel/public/_branding.php diff --git a/db/migrations/008_branding_audit.sql b/db/migrations/008_branding_audit.sql new file mode 100644 index 0000000..c443a9e --- /dev/null +++ b/db/migrations/008_branding_audit.sql @@ -0,0 +1,16 @@ +-- Migration 008: Reseller branding table + +CREATE TABLE IF NOT EXISTS reseller_branding ( + user_id INT UNSIGNED PRIMARY KEY, + panel_name VARCHAR(100) NOT NULL DEFAULT 'NovaCPX', + logo_url VARCHAR(500) DEFAULT NULL, + favicon_url VARCHAR(500) DEFAULT NULL, + primary_color VARCHAR(20) NOT NULL DEFAULT '#6366f1', + accent_color VARCHAR(20) NOT NULL DEFAULT '#0ea5e9', + support_email VARCHAR(255) DEFAULT NULL, + support_url VARCHAR(500) DEFAULT NULL, + hide_powered_by TINYINT(1) NOT NULL DEFAULT 0, + custom_css TEXT DEFAULT NULL, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_branding_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/panel/api/endpoints/branding.php b/panel/api/endpoints/branding.php new file mode 100644 index 0000000..a503c37 --- /dev/null +++ b/panel/api/endpoints/branding.php @@ -0,0 +1,113 @@ +user(); + +// Resolve which reseller's branding we're working with +if ($user['role'] === 'admin') { + $resellerId = (int)($body['reseller_id'] ?? $_GET['reseller_id'] ?? 0); +} elseif ($user['role'] === 'reseller') { + $resellerId = $user['uid']; +} else { + Response::error('Forbidden', 403); +} + +match ($action) { + + 'get' => (function() use ($db, $resellerId) { + if (!$resellerId) Response::error('reseller_id required'); + $row = $db->fetchOne("SELECT * FROM reseller_branding WHERE user_id = ?", [$resellerId]); + Response::success($row ?: ['user_id' => $resellerId]); + })(), + + 'save' => (function() use ($db, $body, $resellerId, $user) { + if ($user['role'] !== 'admin' && $user['role'] !== 'reseller') Response::error('Forbidden', 403); + if (!$resellerId) Response::error('reseller_id required'); + + $allowed = ['panel_name','logo_url','favicon_url','primary_color','accent_color', + 'support_email','support_url','hide_powered_by','custom_css']; + $fields = []; + $vals = []; + foreach ($allowed as $k) { + if (array_key_exists($k, $body)) { + $fields[] = "`$k`"; + $vals[] = $body[$k]; + } + } + if (!$fields) Response::error('No fields to update'); + + // Validate colors + foreach (['primary_color','accent_color'] as $c) { + if (isset($body[$c]) && !preg_match('/^#[0-9a-fA-F]{3,6}$/', $body[$c])) { + Response::error("Invalid color value for $c"); + } + } + + $placeholders = implode(',', array_fill(0, count($fields), '?')); + $setClauses = implode(', ', array_map(fn($f) => "$f = ?", $fields)); + $db->execute( + "INSERT INTO reseller_branding (user_id, " . implode(', ', $fields) . ") + VALUES (?, $placeholders) + ON DUPLICATE KEY UPDATE $setClauses", + array_merge([$resellerId], $vals, $vals) + ); + audit('branding.save', "reseller:$resellerId"); + Response::success(null, 'Branding saved'); + })(), + + 'upload-logo' => (function() use ($resellerId, $user) { + if ($user['role'] !== 'admin' && $user['role'] !== 'reseller') Response::error('Forbidden', 403); + if (!$resellerId) Response::error('reseller_id required'); + + $file = $_FILES['logo'] ?? null; + if (!$file || $file['error'] !== UPLOAD_ERR_OK) Response::error('File upload failed'); + + $allowed = ['image/png','image/jpeg','image/gif','image/svg+xml','image/webp']; + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($file['tmp_name']); + if (!in_array($mime, $allowed)) Response::error('Invalid file type. Allowed: PNG, JPG, GIF, SVG, WebP'); + if ($file['size'] > 512 * 1024) Response::error('Logo must be under 512 KB'); + + $ext = ['image/png'=>'png','image/jpeg'=>'jpg','image/gif'=>'gif', + 'image/svg+xml'=>'svg','image/webp'=>'webp'][$mime] ?? 'png'; + $dir = '/srv/novacpx/public/uploads/branding/' . $resellerId; + if (!is_dir($dir)) mkdir($dir, 0755, true); + + $path = "$dir/logo.$ext"; + // Remove old logo files + foreach (glob("$dir/logo.*") as $old) @unlink($old); + if (!move_uploaded_file($file['tmp_name'], $path)) Response::error('Failed to save logo'); + + $url = "/uploads/branding/{$resellerId}/logo.$ext"; + DB::getInstance()->execute( + "INSERT INTO reseller_branding (user_id, logo_url) VALUES (?,?) + ON DUPLICATE KEY UPDATE logo_url = VALUES(logo_url)", + [$resellerId, $url] + ); + audit('branding.upload-logo', "reseller:$resellerId"); + Response::success(['url' => $url], 'Logo uploaded'); + })(), + + 'delete-logo' => (function() use ($db, $resellerId, $user) { + if ($user['role'] !== 'admin' && $user['role'] !== 'reseller') Response::error('Forbidden', 403); + $dir = '/srv/novacpx/public/uploads/branding/' . $resellerId; + foreach (glob("$dir/logo.*") ?: [] as $f) @unlink($f); + $db->execute("UPDATE reseller_branding SET logo_url = NULL WHERE user_id = ?", [$resellerId]); + Response::success(null, 'Logo removed'); + })(), + + 'resellers' => (function() use ($db, $user) { + Auth::getInstance()->require('admin'); + $rows = $db->fetchAll( + "SELECT u.id, u.username, u.email, b.panel_name, b.logo_url, b.primary_color + FROM users u LEFT JOIN reseller_branding b ON b.user_id = u.id + WHERE u.role = 'reseller' ORDER BY u.username" + ); + Response::success($rows); + })(), + + default => Response::error("Unknown branding action: $action", 404), +}; diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index ec05830..27233c1 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -341,11 +341,27 @@ match ($action) { // ── Audit log ───────────────────────────────────────────────────────────── 'audit-log' => (function() use ($db) { Auth::getInstance()->require('admin'); - $page = max(1, (int)($_GET['page'] ?? 1)); - $perPage = min(100, max(10, (int)($_GET['per_page'] ?? 50))); - $offset = ($page - 1) * $perPage; - $total = $db->fetchOne("SELECT COUNT(*) as c FROM audit_log")['c'] ?? 0; - $rows = $db->fetchAll("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?", [$perPage, $offset]); + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = min(100, max(10, (int)($_GET['per_page'] ?? 50))); + $offset = ($page - 1) * $perPage; + $user = trim($_GET['user'] ?? ''); + $action = trim($_GET['action'] ?? ''); + $dateFrom = trim($_GET['date_from'] ?? ''); + $dateTo = trim($_GET['date_to'] ?? ''); + + $where = 'WHERE 1=1'; + $params = []; + if ($user) { $where .= ' AND username LIKE ?'; $params[] = "%$user%"; } + if ($action) { $where .= ' AND action LIKE ?'; $params[] = "%$action%"; } + if ($dateFrom) { $where .= ' AND created_at >= ?'; $params[] = $dateFrom . ' 00:00:00'; } + if ($dateTo) { $where .= ' AND created_at <= ?'; $params[] = $dateTo . ' 23:59:59'; } + + $total = $db->fetchOne("SELECT COUNT(*) as c FROM audit_log $where", $params)['c'] ?? 0; + $rows = $db->fetchAll( + "SELECT id, user_id, username, action, resource, ip_address, detail, created_at + FROM audit_log $where ORDER BY created_at DESC LIMIT ? OFFSET ?", + [...$params, $perPage, $offset] + ); Response::paginate($rows, (int)$total, $page, $perPage); })(), diff --git a/panel/public/_branding.php b/panel/public/_branding.php new file mode 100644 index 0000000..cab935d --- /dev/null +++ b/panel/public/_branding.php @@ -0,0 +1,80 @@ + before JS loads. + * Reads session cookie → looks up user's reseller → returns branding row. + */ +function novacpx_get_branding(): array { + static $cache = null; + if ($cache !== null) return $cache; + $cfg = @parse_ini_file('/etc/novacpx/config.ini', true); + if (!$cfg) return $cache = []; + try { + $pdo = new PDO( + "mysql:host={$cfg['database']['host']};dbname={$cfg['database']['name']};charset=utf8mb4", + $cfg['database']['user'], $cfg['database']['pass'], + [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] + ); + $token = $_COOKIE['ncpx_session'] ?? ''; + if (!$token || strlen($token) < 32) return $cache = []; + + $stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE token = ? AND expires_at > NOW() LIMIT 1"); + $stmt->execute([substr($token, 0, 128)]); + $uid = (int)($stmt->fetchColumn() ?: 0); + if (!$uid) return $cache = []; + + $stmt = $pdo->prepare("SELECT role, reseller_id FROM users WHERE id = ? LIMIT 1"); + $stmt->execute([$uid]); + $u = $stmt->fetch(); + if (!$u) return $cache = []; + + $resellerId = ($u['role'] === 'reseller') ? $uid : (int)($u['reseller_id'] ?? 0); + if (!$resellerId) return $cache = []; + + $stmt = $pdo->prepare("SELECT * FROM reseller_branding WHERE user_id = ? LIMIT 1"); + $stmt->execute([$resellerId]); + $row = $stmt->fetch(); + return $cache = ($row ?: []); + } catch (Throwable $e) { + return $cache = []; + } +} + +function novacpx_branding_head(): void { + $b = novacpx_get_branding(); + if (!$b) return; + $pc = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['primary_color'] ?? '') ? $b['primary_color'] : null; + $ac = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['accent_color'] ?? '') ? $b['accent_color'] : null; + $css = $b['custom_css'] ?? ''; + echo ' tags + echo preg_replace('/<\s*\/\s*style/i', '', $css) . "\n"; + echo '' . "\n"; + if ($b['favicon_url'] ?? '') { + $fav = htmlspecialchars($b['favicon_url']); + echo "\n"; + } +} + +function novacpx_panel_name(string $default): string { + $b = novacpx_get_branding(); + return htmlspecialchars($b['panel_name'] ?? $default); +} + +function novacpx_logo_html(string $default_svg): string { + $b = novacpx_get_branding(); + if (!empty($b['logo_url'])) { + $url = htmlspecialchars($b['logo_url']); + $name = htmlspecialchars($b['panel_name'] ?? 'Panel'); + return "\"$name\""; + } + return $default_svg; +} + +function novacpx_powered_by(): bool { + $b = novacpx_get_branding(); + return empty($b['hide_powered_by']); +} diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 43815d7..078270d 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -350,28 +350,109 @@ } // ── Audit Log ────────────────────────────────────────────────────────────── - async function auditLog() { - const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } }); - const rows = res?.data || []; - return ` -
-
Audit Log
-
- - - - ${rows.map(r => ` - - - - - - - `).join('')} - -
TimeUserActionResourceIP
${Nova.relTime(r.created_at)}${r.username || '—'}${r.action}${r.resource || '—'}${r.ip_address || '—'}
+ async function auditLog(opts = {}) { + const { page = 1, user = '', action = '', date_from = '', date_to = '' } = opts; + const params = { page, per_page: 50 }; + if (user) params.user = user; + if (action) params.action = action; + if (date_from) params.date_from = date_from; + if (date_to) params.date_to = date_to; + + const content = document.getElementById('page-content'); + const filterBar = ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
`; + + if (content) content.innerHTML = filterBar + '
Loading…
'; + + const res = await Nova.api('system', 'audit-log', { params }); + const rows = res?.data || []; + const meta = res?.meta || {}; + const total = meta.total || rows.length; + const pages = meta.pages || 1; + + const tableHtml = rows.length ? ` +
+ + + + ${rows.map((r, i) => ` + + + + + + + + + + + `).join('')} + +
TimeUserActionResourceIP
${Nova.relTime(r.created_at)}${Nova.escHtml(r.username || '—')}${Nova.escHtml(r.action)}${Nova.escHtml(r.resource || '—')}${Nova.escHtml(r.ip_address || '—')}
+
` : '

No audit entries match the current filters.

'; + + const paginationHtml = pages > 1 ? ` +
+ ${Array.from({length: pages}, (_, i) => i + 1).map(p => ` + + `).join('')} +
` : ''; + + const tableCard = ` +
+
+ Audit Log + ${total} entr${total !== 1 ? 'ies' : 'y'} +
+ ${tableHtml} + ${paginationHtml} +
`; + + if (content) content.innerHTML = filterBar + tableCard; + else return filterBar + tableCard; + + window._alOpts = opts; + } + + window.alToggleDetail = (i) => { + const row = document.getElementById('al-detail-' + i); + if (row) row.style.display = row.style.display === 'none' ? '' : 'none'; + }; + window.alApplyFilter = () => { + auditLog({ + page: 1, + user: document.getElementById('al-user')?.value || '', + action: document.getElementById('al-action')?.value || '', + date_from: document.getElementById('al-from')?.value || '', + date_to: document.getElementById('al-to')?.value || '', + }); + }; + window.alGoPage = (p) => auditLog({ ...(window._alOpts || {}), page: p }); } // ── PHP Manager ──────────────────────────────────────────────────────────── diff --git a/panel/public/assets/js/reseller.js b/panel/public/assets/js/reseller.js index 778fc56..993ce88 100644 --- a/panel/public/assets/js/reseller.js +++ b/panel/public/assets/js/reseller.js @@ -298,8 +298,9 @@ const rNavItems = [ { id:'packages', label:'Packages', icon:'ni-packages' }, { id:'dns', label:'DNS Zones', icon:'ni-dns' }, { id:'docker', label:'Docker', icon:'ni-docker' }, + { id:'whitelabel', label:'White Label', icon:'ni-settings' }, ]; -const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker }; +const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel }; let _rActivePage = 'dashboard'; @@ -482,3 +483,152 @@ window.rDockerLaunchModal = async (appKey, appName) => { if (r?.success) rDockerLoadTab('containers'); }; }; + +// ── White Label / Branding (#18) ──────────────────────────────────────────── +async function rWhiteLabel(el) { + el.innerHTML = '
Loading…
'; + const res = await Nova.api('branding', 'get'); + const b = res?.data || {}; + + el.innerHTML = ` + +
+ +
+
Panel Identity
+
+
+ + +
+
+ + ${b.logo_url ? `
` : ''} +
+ + ${b.logo_url ? `` : ''} + PNG/SVG/JPG · max 512 KB +
+
+
+ + +
+
+
+ +
+
+
Colors
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+ +
+
Support
+
+
+ + +
+
+ + +
+ +
+
+ +
+ + +
+
+
`; + + // Sync color pickers ↔ hex inputs ↔ preview + ['primary','accent'].forEach(k => { + const picker = document.getElementById('wl-'+k); + const hex = document.getElementById('wl-'+k+'-hex'); + const sync = () => { + if (picker) hex.value = picker.value; + rWlUpdatePreview(); + }; + const syncBack = () => { + if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; rWlUpdatePreview(); } + }; + picker?.addEventListener('input', sync); + hex?.addEventListener('input', syncBack); + }); +} + +function rWlUpdatePreview() { + const p = document.getElementById('wl-primary-hex')?.value || '#6366f1'; + const a = document.getElementById('wl-accent-hex')?.value || '#0ea5e9'; + const el = document.getElementById('wl-color-preview'); + if (el) el.style.background = `linear-gradient(135deg,${p},${a})`; + // Live-preview CSS vars + const style = document.getElementById('reseller-branding') || (() => { + const s = document.createElement('style'); s.id = 'reseller-branding'; document.head.appendChild(s); return s; + })(); + style.textContent = `:root { --primary: ${p}; --primary-dark: ${p}; --accent: ${a}; }`; +} + +window.rWlUploadLogo = async () => { + const file = document.getElementById('wl-logo-file')?.files?.[0]; + if (!file) return; + if (file.size > 512 * 1024) { Nova.toast('Logo must be under 512 KB', 'error'); return; } + const fd = new FormData(); + fd.append('logo', file); + Nova.toast('Uploading…', 'info', 5000); + try { + const res = await fetch('/api/branding/upload-logo', { + method: 'POST', credentials: 'include', body: fd + }); + const data = await res.json(); + Nova.toast(data?.success ? 'Logo uploaded' : (data?.message || 'Upload failed'), + data?.success ? 'success' : 'error'); + if (data?.success) rWhiteLabel(document.getElementById('page-content')); + } catch (e) { Nova.toast('Upload failed', 'error'); } +}; + +window.rWlDeleteLogo = async () => { + const r = await Nova.api('branding', 'delete-logo', { method: 'POST' }); + Nova.toast(r?.success ? 'Logo removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) rWhiteLabel(document.getElementById('page-content')); +}; + +window.rWlSave = async () => { + const body = { + panel_name: document.getElementById('wl-name')?.value?.trim() || 'NovaCPX', + primary_color: document.getElementById('wl-primary-hex')?.value || '#6366f1', + accent_color: document.getElementById('wl-accent-hex')?.value || '#0ea5e9', + support_email: document.getElementById('wl-email')?.value?.trim() || '', + support_url: document.getElementById('wl-url')?.value?.trim() || '', + hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0, + custom_css: document.getElementById('wl-css')?.value || '', + }; + const r = await Nova.api('branding', 'save', { method: 'POST', body }); + Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'), + r?.success ? 'success' : 'error'); +}; diff --git a/panel/public/reseller/index.php b/panel/public/reseller/index.php index 95143f4..bcf4cd2 100644 --- a/panel/public/reseller/index.php +++ b/panel/public/reseller/index.php @@ -1,31 +1,26 @@ '?v=' . @filemtime(dirname(__DIR__) . $f); +require_once dirname(__DIR__) . '/_branding.php'; +$_pname = novacpx_panel_name('NovaCPX'); ?> -NovaCPX Reseller +<?= $_pname ?> — Reseller +