mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
#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 <head> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* Branding endpoint — reseller white-label settings
|
||||
*/
|
||||
$db = DB::getInstance();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$user = Auth::getInstance()->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),
|
||||
};
|
||||
@@ -344,8 +344,24 @@ match ($action) {
|
||||
$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]);
|
||||
$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);
|
||||
})(),
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* Server-side branding loader — injected into portal <head> 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 '<style id="reseller-branding">' . "\n";
|
||||
echo ':root {' . "\n";
|
||||
if ($pc) echo " --primary: $pc;\n --primary-dark: $pc;\n";
|
||||
if ($ac) echo " --accent: $ac;\n";
|
||||
echo '}' . "\n";
|
||||
// Sanitize custom CSS — strip </style> tags
|
||||
echo preg_replace('/<\s*\/\s*style/i', '', $css) . "\n";
|
||||
echo '</style>' . "\n";
|
||||
if ($b['favicon_url'] ?? '') {
|
||||
$fav = htmlspecialchars($b['favicon_url']);
|
||||
echo "<link rel=\"icon\" href=\"$fav\">\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 "<img src=\"$url\" alt=\"$name\" style=\"max-height:36px;max-width:160px;object-fit:contain\">";
|
||||
}
|
||||
return $default_svg;
|
||||
}
|
||||
|
||||
function novacpx_powered_by(): bool {
|
||||
$b = novacpx_get_branding();
|
||||
return empty($b['hide_powered_by']);
|
||||
}
|
||||
@@ -350,28 +350,109 @@
|
||||
}
|
||||
|
||||
// ── Audit Log ──────────────────────────────────────────────────────────────
|
||||
async function auditLog() {
|
||||
const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } });
|
||||
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 = `
|
||||
<div class="card" style="margin-bottom:1rem">
|
||||
<div class="card-body" style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
|
||||
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||||
<label style="font-size:.75rem;margin-bottom:.25rem">User</label>
|
||||
<input id="al-user" class="form-control form-control-sm" value="${Nova.escHtml(user)}" placeholder="username…">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||||
<label style="font-size:.75rem;margin-bottom:.25rem">Action</label>
|
||||
<input id="al-action" class="form-control form-control-sm" value="${Nova.escHtml(action)}" placeholder="e.g. account.create">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;min-width:130px">
|
||||
<label style="font-size:.75rem;margin-bottom:.25rem">From</label>
|
||||
<input id="al-from" type="date" class="form-control form-control-sm" value="${Nova.escHtml(date_from)}">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;min-width:130px">
|
||||
<label style="font-size:.75rem;margin-bottom:.25rem">To</label>
|
||||
<input id="al-to" type="date" class="form-control form-control-sm" value="${Nova.escHtml(date_to)}">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="alApplyFilter()">Filter</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="auditLog()">Reset</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (content) content.innerHTML = filterBar + '<div class="page-loader">Loading…</div>';
|
||||
|
||||
const res = await Nova.api('system', 'audit-log', { params });
|
||||
const rows = res?.data || [];
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Audit Log</span></div>
|
||||
<div class="table-wrap">
|
||||
const meta = res?.meta || {};
|
||||
const total = meta.total || rows.length;
|
||||
const pages = meta.pages || 1;
|
||||
|
||||
const tableHtml = rows.length ? `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Resource</th><th>IP</th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Resource</th><th>IP</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map(r => `
|
||||
<tr>
|
||||
<td class="text-muted text-sm">${Nova.relTime(r.created_at)}</td>
|
||||
<td>${r.username || '—'}</td>
|
||||
<td><code>${r.action}</code></td>
|
||||
<td>${r.resource || '—'}</td>
|
||||
<td class="text-muted text-sm">${r.ip_address || '—'}</td>
|
||||
${rows.map((r, i) => `
|
||||
<tr style="cursor:pointer" onclick="alToggleDetail(${i})">
|
||||
<td class="text-muted text-sm" style="white-space:nowrap">${Nova.relTime(r.created_at)}</td>
|
||||
<td>${Nova.escHtml(r.username || '—')}</td>
|
||||
<td><code style="font-size:.8rem">${Nova.escHtml(r.action)}</code></td>
|
||||
<td class="text-muted text-sm">${Nova.escHtml(r.resource || '—')}</td>
|
||||
<td class="text-muted text-sm" style="white-space:nowrap">${Nova.escHtml(r.ip_address || '—')}</td>
|
||||
<td style="width:20px;color:var(--text-muted)">▾</td>
|
||||
</tr>
|
||||
<tr id="al-detail-${i}" style="display:none">
|
||||
<td colspan="6" style="background:var(--bg3);padding:.75rem 1rem">
|
||||
<pre style="margin:0;font-size:.78rem;white-space:pre-wrap;word-break:break-all">${
|
||||
r.detail ? JSON.stringify(JSON.parse(r.detail || '{}'), null, 2) : '(no detail)'
|
||||
}</pre>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : '<div class="empty-state"><p>No audit entries match the current filters.</p></div>';
|
||||
|
||||
const paginationHtml = pages > 1 ? `
|
||||
<div style="display:flex;gap:.5rem;justify-content:center;padding:1rem;flex-wrap:wrap">
|
||||
${Array.from({length: pages}, (_, i) => i + 1).map(p => `
|
||||
<button class="btn btn-sm ${p === page ? 'btn-primary' : 'btn-ghost'}" onclick="alGoPage(${p})">${p}</button>
|
||||
`).join('')}
|
||||
</div>` : '';
|
||||
|
||||
const tableCard = `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Audit Log</span>
|
||||
<span class="text-muted text-sm">${total} entr${total !== 1 ? 'ies' : 'y'}</span>
|
||||
</div>
|
||||
${tableHtml}
|
||||
${paginationHtml}
|
||||
</div>`;
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 = '<div class="page-loader">Loading…</div>';
|
||||
const res = await Nova.api('branding', 'get');
|
||||
const b = res?.data || {};
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1 class="page-title">White Label Branding</h1></div>
|
||||
<div class="grid-2" style="gap:1.5rem;align-items:start">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Panel Identity</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Panel Name</label>
|
||||
<input id="wl-name" class="form-control" value="${Nova.escHtml(b.panel_name||'NovaCPX')}" placeholder="NovaCPX">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Logo</label>
|
||||
${b.logo_url ? `<div style="margin-bottom:.5rem"><img src="${Nova.escHtml(b.logo_url)}" style="max-height:50px;max-width:200px;border-radius:6px;background:var(--bg2);padding:.5rem"></div>` : ''}
|
||||
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||||
<label class="btn btn-ghost btn-sm" style="cursor:pointer">
|
||||
Upload Logo <input type="file" id="wl-logo-file" accept="image/*" style="display:none" onchange="rWlUploadLogo()">
|
||||
</label>
|
||||
${b.logo_url ? `<button class="btn btn-ghost btn-sm" onclick="rWlDeleteLogo()" style="color:var(--danger)">Remove</button>` : ''}
|
||||
<span class="text-muted text-sm">PNG/SVG/JPG · max 512 KB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Custom CSS <span class="text-muted text-sm">(advanced)</span></label>
|
||||
<textarea id="wl-css" class="form-control" rows="4" style="font-family:monospace;font-size:.8rem" placeholder="/* e.g. .sidebar { background: #1a1a2e; } */">${Nova.escHtml(b.custom_css||'')}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:1.5rem">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Colors</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Primary Color</label>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<input type="color" id="wl-primary" value="${Nova.escHtml(b.primary_color||'#6366f1')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
|
||||
<input type="text" id="wl-primary-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.primary_color||'#6366f1')}" maxlength="7">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Accent Color</label>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<input type="color" id="wl-accent" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
|
||||
<input type="text" id="wl-accent-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" maxlength="7">
|
||||
</div>
|
||||
</div>
|
||||
<div id="wl-color-preview" style="height:40px;border-radius:8px;background:linear-gradient(135deg,${Nova.escHtml(b.primary_color||'#6366f1')},${Nova.escHtml(b.accent_color||'#0ea5e9')});transition:background .3s"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Support</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Support Email</label>
|
||||
<input id="wl-email" class="form-control" type="email" value="${Nova.escHtml(b.support_email||'')}" placeholder="support@yourdomain.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Support URL</label>
|
||||
<input id="wl-url" class="form-control" type="url" value="${Nova.escHtml(b.support_url||'')}" placeholder="https://support.yourdomain.com">
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="wl-hide-powered" ${b.hide_powered_by ? 'checked' : ''}>
|
||||
Hide "Powered by NovaCPX" in panel footer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" onclick="rWhiteLabel(document.getElementById('page-content'))">Reset</button>
|
||||
<button class="btn btn-primary" onclick="rWlSave()">Save Branding</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 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');
|
||||
};
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
<?php
|
||||
// NovaCPX Reseller Panel — port 8881
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
$_pname = novacpx_panel_name('NovaCPX');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NovaCPX Reseller</title>
|
||||
<title><?= $_pname ?> — Reseller</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
<?php novacpx_branding_head() ?>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="main-layout" style="display:none">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#rlg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#rlg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#rlg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="rlg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="rlg2" 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)">Reseller</small></span>
|
||||
<?= novacpx_logo_html('<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#rlg1)" stroke-width="2"/><path d="M12 28 L20 8 L28 28" stroke="url(#rlg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22 H26" stroke="url(#rlg2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="rlg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient><linearGradient id="rlg2" 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"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">Reseller</small></span>
|
||||
</div>
|
||||
<nav id="sidebar-nav"></nav>
|
||||
<div class="sidebar-user">
|
||||
@@ -61,8 +56,8 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
<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>
|
||||
</svg>
|
||||
<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 · Port 8881</div>
|
||||
<div style="font-size:1.4rem;font-weight:700"><?= $_pname ?></div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -77,6 +72,14 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NOVACPX_BRANDING = <?= json_encode([
|
||||
'panel_name' => novacpx_panel_name('NovaCPX'),
|
||||
'support_email' => novacpx_get_branding()['support_email'] ?? null,
|
||||
'support_url' => novacpx_get_branding()['support_url'] ?? null,
|
||||
'hide_powered_by' => !novacpx_powered_by(),
|
||||
]) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/reseller.js<?= $_v('/assets/js/reseller.js') ?>"></script>
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<?php
|
||||
// NovaCPX User Panel — End-user hosting dashboard
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
$_pname = novacpx_panel_name('NovaCPX');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NovaCPX — My Hosting</title>
|
||||
<title><?= $_pname ?> — My Hosting</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
<?php novacpx_branding_head() ?>
|
||||
<style>
|
||||
/* ── User panel specific ─────────────────────────────── */
|
||||
.feature-grid {
|
||||
@@ -180,8 +183,8 @@ 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>
|
||||
</defs>
|
||||
</svg>
|
||||
<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 · Port 8880</div>
|
||||
<div style="font-size:1.4rem;font-weight:700"><?= $_pname ?></div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -196,6 +199,14 @@ svg.ring circle { transition: stroke-dashoffset .5s; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NOVACPX_BRANDING = <?= json_encode([
|
||||
'panel_name' => novacpx_panel_name('NovaCPX'),
|
||||
'support_email' => novacpx_get_branding()['support_email'] ?? null,
|
||||
'support_url' => novacpx_get_branding()['support_url'] ?? null,
|
||||
'hide_powered_by' => !novacpx_powered_by(),
|
||||
]) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/user.js<?= $_v('/assets/js/user.js') ?>"></script>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user