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:
2026-06-08 00:50:21 +00:00
parent e2e4fa7fbf
commit 88e98b4727
11 changed files with 355 additions and 2 deletions
+81
View File
@@ -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; }
}
+69 -1
View File
@@ -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);
};
+19
View File
@@ -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(); })
);
});