mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: polish items #26-29 — mobile CSS, error pages, rate limiting, session manager
#26 Mobile responsive: - Hamburger button (SVG) in topbar for all three panels (admin/user/reseller) - Sidebar overlay div for click-outside-to-close on mobile - nova.js: DOMContentLoaded toggle handler with overlay and auto-close on nav click - nova.css: sidebar-overlay, page-header, panel/panel-header, table, btn-success/warning/danger/secondary/xs, badge-muted; mobile media query shows toggle, fixes stats-grid/modal/panel-header layout #27 Custom error pages: - /errors/404.php and /errors/500.php with NovaCPX dark theme matching panel design - Apache ErrorDocument 400/401/403/404/500/503 for ports 8880/8881/8882 with Alias /errors #28 API rate limiting: - api_rate_limits table (migration 004) with per-IP per-bucket counters - api/index.php: 10 req/min for auth endpoint, 120 req/min for all others - Returns X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers - Returns 429 Too Many Requests when exceeded; rate limit failure is non-fatal #29 Session Manager: - sessions.php endpoint: list/revoke/revoke-user/revoke-all - Admin panel Sessions page: table of active sessions with user, role, IP, browser, timestamps - Revoke single session, revoke all for user, revoke all sessions (self-evicts)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
-- Migration 004: API rate limiting table
|
||||
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
||||
ip VARCHAR(45) NOT NULL,
|
||||
endpoint VARCHAR(64) NOT NULL DEFAULT 'api',
|
||||
hits INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
window_start INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (ip, endpoint)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
* Sessions endpoint — admin session management
|
||||
* GET sessions/list — all active sessions with user info
|
||||
* DELETE sessions/revoke — {session_id} revoke one session
|
||||
* DELETE sessions/revoke-user — {user_id} revoke all sessions for a user
|
||||
* DELETE sessions/revoke-all — revoke all sessions except current
|
||||
*/
|
||||
|
||||
Auth::getInstance()->require('admin');
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$db = DB::getInstance();
|
||||
$me = Auth::getInstance()->user();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
match (true) {
|
||||
|
||||
$action === 'list' && $method === 'GET' => (function() use ($db) {
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT s.id, s.user_id, s.ip_address, s.user_agent, s.created_at, s.expires_at,
|
||||
u.username, u.email, u.role
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.expires_at > NOW()
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 200"
|
||||
) ?: [];
|
||||
Response::json(['success' => true, 'data' => $rows]);
|
||||
})(),
|
||||
|
||||
$action === 'revoke' && $method === 'DELETE' => (function() use ($db, $body) {
|
||||
$sid = trim($body['session_id'] ?? '');
|
||||
if (!$sid) Response::error('session_id required', 400);
|
||||
$db->execute("DELETE FROM sessions WHERE id = ?", [$sid]);
|
||||
Response::json(['success' => true]);
|
||||
})(),
|
||||
|
||||
$action === 'revoke-user' && $method === 'DELETE' => (function() use ($db, $body) {
|
||||
$uid = (int)($body['user_id'] ?? 0);
|
||||
if (!$uid) Response::error('user_id required', 400);
|
||||
$count = $db->execute("DELETE FROM sessions WHERE user_id = ?", [$uid]);
|
||||
Response::json(['success' => true, 'data' => ['revoked' => $count]]);
|
||||
})(),
|
||||
|
||||
$action === 'revoke-all' && $method === 'DELETE' => (function() use ($db, $me, $body) {
|
||||
// Keep current session if provided
|
||||
$keepId = $body['keep_session'] ?? null;
|
||||
if ($keepId) {
|
||||
$db->execute("DELETE FROM sessions WHERE id != ?", [hash('sha256', $keepId)]);
|
||||
} else {
|
||||
$db->execute("DELETE FROM sessions");
|
||||
}
|
||||
Response::json(['success' => true]);
|
||||
})(),
|
||||
|
||||
default => Response::error('Not found', 404),
|
||||
};
|
||||
@@ -56,4 +56,38 @@ if (!file_exists($endpointFile)) {
|
||||
Response::error("Unknown endpoint: $endpoint", 404);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// #28 Rate limiting — per-IP, per-endpoint bucket
|
||||
(function() use ($endpoint) {
|
||||
$db = DB::getInstance();
|
||||
$ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0";
|
||||
$now = time();
|
||||
$window = 60;
|
||||
$limit = $endpoint === "auth" ? 10 : 120;
|
||||
$bucket = $endpoint === "auth" ? "auth" : "api";
|
||||
try {
|
||||
$row = $db->fetchOne("SELECT hits, window_start FROM api_rate_limits WHERE ip=? AND endpoint=?", [$ip, $bucket]);
|
||||
if ($row && ($now - (int)$row["window_start"]) < $window) {
|
||||
$hits = (int)$row["hits"] + 1;
|
||||
$db->execute("UPDATE api_rate_limits SET hits=? WHERE ip=? AND endpoint=?", [$hits, $ip, $bucket]);
|
||||
} else {
|
||||
$hits = 1;
|
||||
$db->execute("INSERT INTO api_rate_limits (ip, endpoint, hits, window_start) VALUES (?,?,1,?) ON DUPLICATE KEY UPDATE hits=1, window_start=VALUES(window_start)", [$ip, $bucket, $now]);
|
||||
}
|
||||
$reset = ($row ? (int)$row["window_start"] : $now) + $window;
|
||||
$remaining = max(0, $limit - $hits);
|
||||
header("X-RateLimit-Limit: {$limit}");
|
||||
header("X-RateLimit-Remaining: {$remaining}");
|
||||
header("X-RateLimit-Reset: {$reset}");
|
||||
if ($hits > $limit) {
|
||||
http_response_code(429);
|
||||
echo json_encode(["success"=>false,"message"=>"Too many requests. Try again in " . ($reset - $now) . " seconds.","errors"=>[]]);
|
||||
exit;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
novacpx_log("warn", "rate limit error: " . $e->getMessage());
|
||||
}
|
||||
})();
|
||||
|
||||
require $endpointFile;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="app" style="display:none">
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
@@ -125,6 +126,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1" fill="currentColor"/></svg>
|
||||
2FA Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="sessions">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
||||
Sessions
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
@@ -165,7 +170,8 @@
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
|
||||
<div class="topbar-title" id="page-title">Dashboard</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="server-ip" class="text-muted text-sm"></span>
|
||||
|
||||
@@ -300,3 +300,84 @@ code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em;
|
||||
.text-muted { color: var(--text-muted); } .text-sm { font-size: .82rem; }
|
||||
.text-right { text-align: right; } .font-bold { font-weight: 700; }
|
||||
.w-full { width: 100%; } .hidden { display: none; }
|
||||
|
||||
/* ── #26 Mobile Responsive Additions ────────────────────────────────────────── */
|
||||
#sidebar-toggle { display: none; }
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.5); z-index: 99;
|
||||
}
|
||||
.sidebar-overlay.open { display: block; }
|
||||
|
||||
/* page-header layout */
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 1.5rem; flex-wrap: wrap; gap: .75rem;
|
||||
}
|
||||
.page-title { font-size: 1.2rem; font-weight: 700; }
|
||||
.page-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* panel utility */
|
||||
.panel {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); margin-bottom: 1.5rem;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: .5rem;
|
||||
}
|
||||
.panel-title { font-size: .95rem; font-weight: 600; }
|
||||
.panel-body { padding: 1.25rem; }
|
||||
|
||||
/* table alias */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.table td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tr:hover td { background: var(--bg3); }
|
||||
|
||||
/* btn variants */
|
||||
.btn-success { background: var(--green); color: #fff; }
|
||||
.btn-success:hover { background: #0da271; }
|
||||
.btn-warning { background: var(--yellow); color: #000; }
|
||||
.btn-warning:hover { background: #d97706; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-secondary { background: var(--bg3); border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-xs { padding: .2rem .55rem; font-size: .75rem; border-radius: 6px; }
|
||||
|
||||
/* badge alias */
|
||||
.badge-muted { background: rgba(148,163,184,.15); color: #94a3b8; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar-toggle { display: flex; }
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform .25s ease;
|
||||
z-index: 200;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
|
||||
.main-content { margin-left: 0; }
|
||||
|
||||
.page-header { flex-direction: column; align-items: flex-start; }
|
||||
.page-actions { width: 100%; }
|
||||
|
||||
.stats-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
|
||||
.modal { max-width: calc(100vw - 2rem); margin: 1rem; }
|
||||
|
||||
.panel-header { flex-direction: column; align-items: flex-start; }
|
||||
|
||||
.topbar { padding: .65rem 1rem; }
|
||||
|
||||
/* hide non-essential table columns on mobile */
|
||||
.table th:nth-child(n+4),
|
||||
.table td:nth-child(n+4) { display: none; }
|
||||
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
'mail-server': mailServer,
|
||||
'ftp-server': ftpServer,
|
||||
'nginx-proxy': nginxProxy,
|
||||
sessions,
|
||||
wordpress,
|
||||
'ssl-manager': sslManager,
|
||||
firewall,
|
||||
@@ -1343,7 +1344,8 @@ ${ips.length ? `
|
||||
async function wordpress() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
|
||||
async function cloudflare() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
|
||||
async function twofa() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
|
||||
async function nginxProxy() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
|
||||
async function nginxProxy() { return `<p class="text-muted">Loading...</p>`; }
|
||||
async function sessions() { return `<p class="text-muted">Loading...</p>`; }
|
||||
|
||||
// ── Global action helpers ──────────────────────────────────────────────────
|
||||
window.adminPage = (page) => Nova.loadPage(page, pages);
|
||||
@@ -2168,3 +2170,69 @@ window.proxySetupInstructions = async () => {
|
||||
</div>
|
||||
`, null, { cancelLabel: 'Close', showConfirm: false });
|
||||
};
|
||||
|
||||
// ── #29 Session Manager ───────────────────────────────────────────────────────
|
||||
async function sessions() {
|
||||
const r = await Nova.api('sessions', 'list');
|
||||
const rows = r?.data || [];
|
||||
const fmt = d => new Date(d.replace(' ','T')+'Z').toLocaleString();
|
||||
const ua = s => {
|
||||
if (!s) return '—';
|
||||
const m = s.match(/\(([^)]+)\)/);
|
||||
return m ? m[1].split(';')[0].slice(0,50) : s.slice(0,50);
|
||||
};
|
||||
return `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Session Manager</h1>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-sm btn-danger" onclick="sessionsRevokeAll()">Revoke All Sessions</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
<div class="stat-card"><div class="stat-label">Active Sessions</div><div class="stat-value">${rows.length}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Unique Users</div><div class="stat-value">${new Set(rows.map(r=>r.user_id)).size}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Unique IPs</div><div class="stat-value">${new Set(rows.map(r=>r.ip_address)).size}</div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Active Sessions</h3><span class="badge badge-blue">${rows.length} total</span></div>
|
||||
${rows.length === 0
|
||||
? '<div style="padding:2rem;text-align:center;color:var(--text-muted)">No active sessions</div>'
|
||||
: `<div style="overflow-x:auto"><table class="table"><thead><tr>
|
||||
<th>User</th><th>Role</th><th>IP</th><th>Browser</th><th>Created</th><th>Expires</th><th>Actions</th>
|
||||
</tr></thead><tbody>
|
||||
${rows.map(s=>`<tr>
|
||||
<td><strong>${Nova.escHtml(s.username)}</strong><br><small class="text-muted">${Nova.escHtml(s.email)}</small></td>
|
||||
<td>${Nova.badge(s.role, s.role==='admin'?'red':s.role==='reseller'?'yellow':'blue')}</td>
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(s.ip_address)}</td>
|
||||
<td style="font-size:.8rem;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${Nova.escHtml(s.user_agent||'')}">${Nova.escHtml(ua(s.user_agent||''))}</td>
|
||||
<td style="font-size:.82rem">${fmt(s.created_at)}</td>
|
||||
<td style="font-size:.82rem">${fmt(s.expires_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-xs btn-danger" onclick="sessionsRevoke('${s.id}')">Revoke</button>
|
||||
<button class="btn btn-xs btn-warning" onclick="sessionsRevokeUser(${s.user_id},'${Nova.escHtml(s.username)}')">All for User</button>
|
||||
</td></tr>`).join('')}
|
||||
</tbody></table></div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window.sessionsRevoke = async (id) => {
|
||||
const r = await Nova.api('sessions','revoke',{method:'DELETE',body:{session_id:id}});
|
||||
Nova.toast(r?.success?'Session revoked':'Failed',r?.success?'success':'error');
|
||||
if (r?.success) Nova.loadPage('sessions',window._novaPages);
|
||||
};
|
||||
|
||||
window.sessionsRevokeUser = (uid,name) => {
|
||||
Nova.confirm(`Revoke all sessions for ${name}? They will be logged out everywhere.`,async()=>{
|
||||
const r=await Nova.api('sessions','revoke-user',{method:'DELETE',body:{user_id:uid}});
|
||||
Nova.toast(r?.success?`${r.data?.revoked??'?'} sessions revoked`:'Failed',r?.success?'success':'error');
|
||||
if(r?.success) Nova.loadPage('sessions',window._novaPages);
|
||||
},true);
|
||||
};
|
||||
|
||||
window.sessionsRevokeAll = () => {
|
||||
Nova.confirm('Revoke ALL sessions? Everyone including you will be logged out.',async()=>{
|
||||
const r=await Nova.api('sessions','revoke-all',{method:'DELETE',body:{}});
|
||||
Nova.toast(r?.success?'All sessions revoked — logging out...':'Failed',r?.success?'success':'error');
|
||||
if(r?.success) setTimeout(()=>location.reload(),1500);
|
||||
},true);
|
||||
};
|
||||
|
||||
@@ -127,3 +127,22 @@ window.Nova = (() => {
|
||||
|
||||
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml };
|
||||
})();
|
||||
|
||||
// #26 Mobile sidebar toggle — shared across all panels
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggle = document.getElementById('sidebar-toggle');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
if (!toggle || !sidebar) return;
|
||||
|
||||
const open = () => { sidebar.classList.add('open'); overlay?.classList.add('open'); document.body.style.overflow = 'hidden'; };
|
||||
const close = () => { sidebar.classList.remove('open'); overlay?.classList.remove('open'); document.body.style.overflow = ''; };
|
||||
|
||||
toggle.addEventListener('click', () => sidebar.classList.contains('open') ? close() : open());
|
||||
overlay?.addEventListener('click', close);
|
||||
|
||||
// Close when a nav link is clicked on mobile
|
||||
sidebar.querySelectorAll('.sidebar-link').forEach(link =>
|
||||
link.addEventListener('click', () => { if (window.innerWidth <= 768) close(); })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php http_response_code(404); ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — Page Not Found · NovaCPX</title>
|
||||
<style>
|
||||
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||
background-image:radial-gradient(ellipse at 30% 20%,rgba(99,102,241,.12) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(239,68,68,.07) 0%,transparent 60%)}
|
||||
.wrap{text-align:center;padding:2rem;max-width:480px}
|
||||
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,var(--primary),#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
|
||||
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
|
||||
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
|
||||
.btn:hover{opacity:.85}
|
||||
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
|
||||
.logo svg{width:28px;height:28px}
|
||||
.logo-text{font-size:1.1rem;font-weight:300}
|
||||
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
|
||||
<div class="logo-text">Nova<strong>CPX</strong></div>
|
||||
</div>
|
||||
<div class="code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="javascript:history.back()" class="btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
Go Back
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php http_response_code(500); ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>500 — Server Error · NovaCPX</title>
|
||||
<style>
|
||||
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||
background-image:radial-gradient(ellipse at 30% 20%,rgba(239,68,68,.1) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(99,102,241,.07) 0%,transparent 60%)}
|
||||
.wrap{text-align:center;padding:2rem;max-width:480px}
|
||||
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,#ef4444,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
|
||||
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
|
||||
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
|
||||
.btn:hover{opacity:.85}
|
||||
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
|
||||
.logo svg{width:28px;height:28px}
|
||||
.logo-text{font-size:1.1rem;font-weight:300}
|
||||
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
|
||||
<div class="logo-text">Nova<strong>CPX</strong></div>
|
||||
</div>
|
||||
<div class="code">500</div>
|
||||
<h1>Internal Server Error</h1>
|
||||
<p>Something went wrong on our end. The issue has been logged. Please try again in a moment.</p>
|
||||
<a href="javascript:history.back()" class="btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
Go Back
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
<div class="topbar-title" id="page-title">Reseller Dashboard</div>
|
||||
</header>
|
||||
<div class="page-content" id="page-content"></div>
|
||||
|
||||
@@ -158,6 +158,7 @@ svg.ring circle { transition: stroke-dashoffset .5s; }
|
||||
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
<div class="topbar-title" id="page-title">My Hosting</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="account-domain" class="text-muted text-sm"></span>
|
||||
|
||||
Reference in New Issue
Block a user