diff --git a/README.md b/README.md index 9b777ca..7c1d60d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Game server management dashboard for the ZeroHost platform. Provides user-facing server lifecycle management (creation, renewal, suspension, deletion) backed by a Pyrodactyl/Pterodactyl panel API. +image + --- ## Table of Contents diff --git a/config/cap.js b/config/cap.js index b885250..1bfb964 100644 --- a/config/cap.js +++ b/config/cap.js @@ -1,14 +1,6 @@ const CAP_SECRET = process.env.CAP_SECRET; const CAP_ENDPOINT = process.env.CAP_ENDPOINT; -if (!CAP_SECRET) { - console.error('Missing CAP_SECRET environment variable'); -} - -if (!CAP_ENDPOINT) { - console.error('Missing CAP_ENDPOINT environment variable'); -} - async function fetchWithTimeout(url, options = {}, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); diff --git a/config/db.js b/config/db.js index 4967a60..4e97881 100644 --- a/config/db.js +++ b/config/db.js @@ -17,18 +17,50 @@ const pool = mariadb.createPool({ acquireTimeout: 10000, idleTimeout: 30000, insertIdAsNumber: true, + pingTimeout: 5000, }); +export async function closePool() { + try { + await pool.end(); + } catch (err) { + console.error('Error closing pool:', err.message); + } +} + +export async function getPoolStatus() { + try { + const active = pool.activeConnections(); + const total = pool.totalConnections(); + const idle = pool.idleConnections(); + return { active, total, idle }; + } catch { + return { active: -1, total: -1, idle: -1 }; + } +} + export async function query(sql, params = []) { let lastErr; for (let attempt = 1; attempt <= 3; attempt++) { let conn; + let queryTimeout; try { conn = await pool.getConnection(); - const rows = await conn.query(sql, params); + const timeoutPromise = new Promise((_, reject) => { + queryTimeout = setTimeout(() => reject(new Error('Query timeout after 30000ms')), 30000); + }); + let rows = await Promise.race([ + conn.query(sql, params), + timeoutPromise, + ]); + clearTimeout(queryTimeout); + if (Array.isArray(rows) && rows.length > 10000) { + console.warn(`Large result set detected: ${rows.length} rows for query: ${sql.slice(0, 100)}`); + } return rows; } catch (err) { lastErr = err; + clearTimeout(queryTimeout); if (err.code === 'ECONNRESET' || err.code === 'PROTOCOL_CONNECTION_LOST' || err.message?.includes('timeout')) { console.error(`DB query attempt ${attempt}/3 failed:`, err.message); if (attempt < 3) await new Promise(r => setTimeout(r, 100 * attempt)); diff --git a/config/migrate.js b/config/migrate.js index bde37db..ab3d0c0 100644 --- a/config/migrate.js +++ b/config/migrate.js @@ -56,6 +56,8 @@ const tables = { { name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' }, { name: 'ptero_nest_id', def: 'INT NOT NULL UNIQUE' }, { name: 'name', def: 'VARCHAR(255) NOT NULL' }, + { name: 'logo', def: 'VARCHAR(255) DEFAULT NULL' }, + { name: 'description', def: 'TEXT DEFAULT NULL' }, { name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, ], }, @@ -64,6 +66,7 @@ const tables = { { name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' }, { name: 'ptero_nest_id', def: 'INT NOT NULL' }, { name: 'ptero_egg_id', def: 'INT NOT NULL' }, + { name: 'logo', def: 'VARCHAR(255) DEFAULT NULL' }, { name: 'cpu_limit', def: 'INT DEFAULT NULL' }, { name: 'memory_limit', def: 'INT DEFAULT NULL' }, { name: 'disk_limit', def: 'INT DEFAULT NULL' }, @@ -106,7 +109,14 @@ const constraints = [ { table: 'egg_resources', sql: 'ALTER TABLE egg_resources ADD UNIQUE INDEX idx_egg_resources_nest_egg (ptero_nest_id, ptero_egg_id)', name: 'idx_egg_resources_nest_egg' }, { table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user (user_id)', name: 'idx_notif_user' }, { table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user_read (user_id, is_read)', name: 'idx_notif_user_read' }, + { table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_created (created_at)', name: 'idx_notif_created' }, { table: 'notifications', sql: 'ALTER TABLE notifications ADD CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_notif_user' }, + { table: 'users', sql: 'ALTER TABLE users ADD INDEX idx_email (email)', name: 'idx_user_email' }, + { table: 'users', sql: 'ALTER TABLE users ADD INDEX idx_username (username)', name: 'idx_user_username' }, + { table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_action (action)', name: 'idx_activity_action' }, + { table: 'activity_log', sql: 'ALTER TABLE activity_log ADD CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_activity_user' }, + { table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_ptero_server (ptero_server_id)', name: 'idx_ptero_server' }, + { table: 'server_meta', sql: 'ALTER TABLE server_meta ADD CONSTRAINT fk_server_meta_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_server_meta_user' }, ]; export async function migrate() { diff --git a/config/pyrodactyl.js b/config/pyrodactyl.js index e06647c..cbe1cba 100644 --- a/config/pyrodactyl.js +++ b/config/pyrodactyl.js @@ -1,6 +1,6 @@ export const PTERO_URL = process.env.PTERO_URL || 'https://panel.zero-host.org'; export const PTERO_API_KEY = process.env.PTERO_API_KEY || ''; -export const PANEL_DB_NAME = process.env.PANEL_DB_NAME || 'panel'; +export const PANEL_DB_NAME = (process.env.PANEL_DB_NAME || 'panel').replace(/[^a-zA-Z0-9_]/g, ''); export const SERVER_LIMITS = { memory: 512, diff --git a/middleware/auth.js b/middleware/auth.js index e09e59d..2006c9a 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -3,12 +3,6 @@ import { query } from '../config/db.js'; const JWT_SECRET = process.env.JWT_SECRET; -if (!JWT_SECRET) { - console.error('Missing JWT_SECRET environment variable'); -} else if (/[\$\(\)]/.test(JWT_SECRET)) { - console.error('JWT_SECRET contains unresolved shell expansion characters ($(), backticks). Generate a proper random key (e.g. openssl rand -hex 32) and hardcode it in .env'); -} - export async function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; diff --git a/package.json b/package.json index 704316b..cd1844f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.0.2", + "version": "1.0.3", "private": true, "type": "module", "scripts": { diff --git a/public/css/style.css b/public/css/style.css index cc73afc..b5fbb34 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,2343 +1,2707 @@ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg-primary: #000; - --bg-secondary: #1c1917; - --bg-card: #1c1917; - --bg-card-hover: #292524; - --bg-sidebar: #0d0d0d; - --border: rgba(238, 129, 50, 0.15); - --border-hover: rgba(238, 129, 50, 0.3); - --accent-1: #ee8132; - --accent-2: #ee8132; - --accent-3: #ee8132; - --accent-cyan: #06b6d4; - --accent-green: #059669; - --accent-red: #ef4444; - --accent-orange: #f59e0b; - --text-primary: #f5f5f4; - --text-secondary: #a8a29e; - --text-muted: #78716c; - --glow-primary: rgba(238, 129, 50, 0.15); - --radius-sm: 8px; - --radius-md: 14px; - --radius-lg: 20px; - --radius-xl: 28px; - --transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1); - --sidebar-w: 260px; -} - -html { height: 100%; } - -body, body * { - font-family: 'Schibsted Grotesk', system-ui, sans-serif; - font-weight: 400; -} - -body { - height: 100%; - background: var(--bg-primary); - color: var(--text-primary); - line-height: 1.6; - overflow-x: hidden; -} - -::-webkit-scrollbar { width: 6px; } -::-webkit-scrollbar-track { background: var(--bg-primary); } -::-webkit-scrollbar-thumb { background: var(--accent-1); border-radius: 99px; } - -a { color: var(--accent-3); text-decoration: none; } -a:hover { color: var(--accent-1); } - -/* ===== AUTH PAGES ===== */ -.auth-page { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: #000; - position: relative; - padding: 24px; -} - -.auth-page::before { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 0; - background-image: - linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); - background-size: 24px 24px; - mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); - -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); -} - -.auth-page::after { - content: ''; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E"); - background-repeat: repeat; - background-size: 512px 512px; -} - -.auth-card { - position: relative; - z-index: 1; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-xl); - padding: 48px 40px; - width: 100%; - max-width: 440px; - box-shadow: 0 24px 80px rgba(0,0,0,0.5); -} - -.auth-logo { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 8px; -} - -.auth-logo img { - width: 36px; - height: 36px; - border-radius: var(--radius-sm); -} - -.auth-logo-text { - font-size: 1.4rem; - font-weight: 800; - letter-spacing: -0.02em; -} - -.auth-logo-accent { color: var(--accent-3); } - -.auth-title { - font-size: 1.6rem; - font-weight: 700; - margin-bottom: 6px; -} - -.auth-subtitle { - font-size: 0.9rem; - color: var(--text-secondary); - margin-bottom: 32px; -} - -.form-group { - margin-bottom: 20px; -} - -.form-group label { - display: block; - font-size: 0.82rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 6px; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.form-group input, .form-group select, .form-group textarea { - width: 100%; - padding: 12px 16px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-family: 'Schibsted Grotesk', sans-serif; - font-size: 0.95rem; - transition: all var(--transition); - outline: none; -} - -.form-group input:focus, .form-group select:focus, .form-group textarea:focus { - border-color: var(--accent-1); - box-shadow: 0 0 0 3px rgba(238, 129, 50, 0.15); -} - -.form-group input::placeholder { - color: var(--text-muted); -} - -.form-group select { - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; -} - -/* Custom Select */ -.custom-select { - position: relative; - width: 100%; -} - -.custom-select-trigger { - width: 100%; - padding: 12px 16px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-family: 'Schibsted Grotesk', sans-serif; - font-size: 0.95rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - transition: all var(--transition); - outline: none; - text-align: left; -} - -.custom-select-trigger:hover { - border-color: var(--border-hover); -} - -.custom-select-trigger:focus, -.custom-select.open .custom-select-trigger { - border-color: var(--accent-1); - box-shadow: 0 0 0 3px rgba(238, 129, 50, 0.15); -} - -.custom-select-arrow { - flex-shrink: 0; - color: var(--text-secondary); - transition: transform var(--transition); -} - -.custom-select.open .custom-select-arrow { - transform: rotate(180deg); -} - -.custom-select-dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - box-shadow: 0 12px 40px rgba(0,0,0,0.5); - z-index: 50; - max-height: 280px; - overflow-y: auto; - display: none; -} - -.custom-select-dropdown.open { - display: block; -} - -.custom-select-option { - padding: 10px 16px 10px 20px; - font-size: 0.9rem; - color: var(--text-secondary); - cursor: pointer; - transition: all var(--transition); - border-left: 2px solid transparent; -} - -.custom-select-option:hover { - background: rgba(238, 129, 50, 0.08); - color: var(--text-primary); - border-left-color: var(--accent-1); -} - -.custom-select-option:first-child { - border-radius: var(--radius-sm) var(--radius-sm) 0 0; -} - -.custom-select-option:last-child { - border-radius: 0 0 var(--radius-sm) var(--radius-sm); -} - -.custom-select-category { - padding: 14px 16px 6px; - font-size: 0.65rem; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--accent-1); - cursor: default; - border-top: 1px solid rgba(238, 129, 50, 0.12); - margin-top: 6px; -} - -.custom-select-category:first-child { - border-top: none; - margin-top: 0; - padding-top: 12px; -} - -.form-row { - display: flex; - gap: 12px; -} - -cap-widget { - display: block !important; - width: 100% !important; - min-width: 100% !important; - --cap-background: #1c1917; - --cap-color: #f5f5f4; - --cap-border-color: rgba(238, 129, 50, 0.15); - --cap-checkbox-border-radius: 8px; - --cap-border-radius: 8px; - --cap-checkbox-background: transparent; - --cap-spinner-color: #ee8132; - --cap-spinner-background-color: rgba(238, 129, 50, 0.1); - --cap-widget-width: 100%; -} - -.form-row .form-group { - flex: 1; -} - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - font-family: 'Schibsted Grotesk', sans-serif; - font-size: 0.9rem; - font-weight: 600; - border-radius: var(--radius-md); - padding: 12px 22px; - border: none; - cursor: pointer; - text-decoration: none; - transition: all var(--transition); - white-space: nowrap; -} - -.btn-primary { - background: linear-gradient(180deg, #433b32 0%, #29241f 100%); - color: #fff; - border: 1px solid rgba(255, 237, 217, 0.2); -} - -.btn-primary:hover { - color: #fff; - transform: translateY(-2px); - border-color: rgba(238, 129, 50, 0.4); -} - -.btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.btn-ghost { - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border); -} - -.btn-ghost:hover { - color: var(--text-primary); - border-color: var(--border-hover); - background: rgba(238, 129, 50, 0.06); -} - -.btn-danger { - background: linear-gradient(135deg, var(--accent-red), #dc2626); - color: #fff; - box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3); -} - -.btn-danger:hover { - color: #fff; - transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(239, 68, 68, 0.45); -} - -.btn-warning { - background: linear-gradient(135deg, #f59e0b, #d97706); - color: #fff; - box-shadow: 0 4px 20px rgba(245, 158, 11, 0.3); -} - -.btn-warning:hover { - color: #fff; - transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(245, 158, 11, 0.45); -} - -.btn-warning:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.btn-sm { - padding: 8px 16px; - font-size: 0.82rem; -} - -.btn-full { - width: 100%; - justify-content: center; -} - -.auth-footer { - text-align: center; - margin-top: 24px; - font-size: 0.88rem; - color: var(--text-secondary); -} - -.auth-footer a { - color: var(--accent-3); - font-weight: 600; -} - -.auth-error { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); - color: var(--accent-red); - padding: 12px 16px; - border-radius: var(--radius-sm); - font-size: 0.85rem; - margin-bottom: 20px; - display: none; -} - -.auth-error.show { - display: block; -} - -/* ===== DASHBOARD LAYOUT ===== */ -.dashboard-layout { - display: flex; - min-height: 100vh; - position: relative; -} - -.dashboard-layout::before { - content: ''; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background-image: - linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); - background-size: 24px 24px; - mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); - -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); -} - -.dashboard-layout::after { - content: ''; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E"); - background-repeat: repeat; - background-size: 512px 512px; -} - -.sidebar { - width: var(--sidebar-w); - background: var(--bg-sidebar); - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - position: fixed; - top: 0; - left: 0; - bottom: 0; - z-index: 100; - overflow: hidden; - transition: transform var(--transition); -} - -.sidebar.resizing { - transition: none; -} - -.sidebar-resizer { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 5px; - cursor: col-resize; - z-index: 10; - background: transparent; - transition: background 0.15s; -} - -.sidebar-resizer:hover, -.sidebar.resizing .sidebar-resizer { - background: var(--accent-1); -} - -.sidebar.collapsed { - width: 60px; -} - -.sidebar.collapsed .sidebar-resizer { - display: none; -} - -.sidebar.collapsed .sidebar-logo { - justify-content: center; -} - -.sidebar.collapsed .sidebar-logo-text { - display: none; -} - -.sidebar.collapsed .sidebar-header { - padding: 16px 12px; - justify-content: center; -} - -.sidebar.collapsed .sidebar-nav { - padding: 12px 8px; -} - -.sidebar.collapsed .nav-section-label { - display: none; -} - -.sidebar.collapsed .nav-item { - font-size: 0; - justify-content: center; - padding: 10px 0; - gap: 0; -} - -.sidebar.collapsed .nav-indicator { - left: 8px; - width: calc(100% - 16px); -} - -.sidebar.collapsed .nav-item svg { - font-size: 1rem; - width: 20px; - height: 20px; -} - -.sidebar.collapsed .sidebar-footer { - border-top: none; - padding: 8px 0 0; - text-align: center; -} - -.sidebar.collapsed #logout-btn { - display: none !important; -} - -.sidebar.collapsed .user-info { - display: block !important; - padding: 0 !important; - text-align: center !important; -} - -.sidebar.collapsed #sidebar-user-info > div:first-child { - gap: 0; - display: inline-flex !important; - overflow: visible !important; -} - -.sidebar.collapsed #sidebar-user-info > div:first-child > div:last-child { - display: none; -} - -.sidebar.collapsed .user-name, -.sidebar.collapsed .user-email { - display: none; -} - -.sidebar.collapsed .sidebar-footer > div:not(.user-info) { - display: none; -} - -.sidebar.collapsed ~ .main-content { - margin-left: 60px; -} - -.sidebar-tooltip { - position: fixed; - z-index: 10000; - background: var(--bg-card); - color: var(--text-primary); - padding: 6px 12px; - border-radius: var(--radius-sm); - font-size: 0.85rem; - white-space: nowrap; - pointer-events: none; - opacity: 0; - transform: translateY(-50%); - transition: opacity 0.15s ease; - box-shadow: 0 4px 12px rgba(0,0,0,0.4); - border: 1px solid var(--border); -} - -.sidebar-tooltip.visible { - opacity: 1; -} - -.main-content { - position: relative; - z-index: 1; -} - -.sidebar-header { - padding: 20px 20px 16px; - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - gap: 10px; -} - -.sidebar-logo { - display: flex; - align-items: center; - gap: 10px; - text-decoration: none; -} - -.sidebar-logo img { - width: 28px; - height: 28px; - border-radius: 6px; -} - -.sidebar-logo-text { - font-size: 1.1rem; - font-weight: 800; - color: var(--text-primary); - letter-spacing: -0.02em; -} - -.sidebar-nav { - flex: 1; - padding: 8px 12px; - overflow-y: auto; - position: relative; -} - -.nav-indicator { - position: absolute; - left: 12px; - width: calc(100% - 24px); - background: rgba(238, 129, 50, 0.12); - border-radius: var(--radius-sm); - border-left: 3px solid var(--accent-1); - transition: top var(--transition), height var(--transition), opacity var(--transition); - pointer-events: none; - z-index: 1; -} - -.nav-section-label { - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-muted); - padding: 16px 12px 6px; -} - -.nav-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: color var(--transition); - text-decoration: none; - margin-bottom: 2px; - position: relative; - z-index: 2; -} - -.nav-item:hover { - color: var(--text-primary); - background: rgba(238, 129, 50, 0.08); -} - -.nav-item.active { - color: var(--accent-1); -} - -.nav-item svg { - width: 18px; - height: 18px; - flex-shrink: 0; -} - -.sidebar-footer { - padding: 12px 12px 4px; - border-top: 1px solid var(--border); -} - -.user-info { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; -} - -.user-info > div:first-child { - min-width: 0; - overflow: hidden; -} -.user-info > div:first-child > div:last-child { - min-width: 0; - overflow: hidden; -} - -.user-avatar { - width: 32px; - height: 32px; - border-radius: 50%; - background: var(--accent-1); - display: flex; - align-items: center; - justify-content: center; - font-size: 0.8rem; - font-weight: 700; - color: #fff; - flex-shrink: 0; -} - -.user-name { - font-size: 0.85rem; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.user-email { - font-size: 0.75rem; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.main-content { - flex: 1; - margin-left: var(--sidebar-w); - padding: 32px; - min-height: 100vh; -} - -/* ===== DASHBOARD PAGES ===== */ -.page { - display: none; -} - -.page.active { - display: block; -} - -.page-header { - margin-bottom: 32px; -} - -.page-title { - font-size: 1.8rem; - font-weight: 800; - letter-spacing: -0.02em; - margin-bottom: 6px; -} - -.page-subtitle { - font-size: 0.92rem; - color: var(--text-secondary); -} - -/* ===== CARDS ===== */ -.card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 24px; - transition: all var(--transition); -} - -.card:hover { - border-color: var(--border-hover); -} - -.card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.card-title { - font-size: 1rem; - font-weight: 700; -} - -.stat-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 20px; - margin-bottom: 32px; -} - -.stat-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 24px; - position: relative; - overflow: hidden; -} - -.stat-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 4px; - height: 100%; - background: linear-gradient(180deg, var(--accent-1), var(--accent-2)); - border-radius: 0 2px 2px 0; -} - -.stat-icon { - width: 40px; - height: 40px; - border-radius: var(--radius-sm); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; - color: var(--accent-1); - background: rgba(238, 129, 50, 0.12); -} - -.stat-value { - font-size: 2rem; - font-weight: 800; - letter-spacing: -0.03em; - margin-bottom: 4px; -} - -.stat-label { - font-size: 0.82rem; - color: var(--text-secondary); - font-weight: 500; -} - -.chart-container { - position: relative; - max-width: 320px; - margin: 0 auto; -} - -.chart-container canvas { - width: 100% !important; - height: auto !important; -} - -/* ===== SERVER LIST ===== */ -.server-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: 16px; -} - -.server-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 20px; - transition: all var(--transition); - cursor: pointer; -} - -.server-card:hover { - border-color: var(--border-hover); - background: var(--bg-card-hover); - transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(0,0,0,0.3); -} - -.server-card-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 12px; -} - -.server-card-name { - font-size: 1rem; - font-weight: 700; - color: var(--text-primary); -} - -.server-card-status { - font-size: 0.7rem; - font-weight: 600; - padding: 3px 10px; - border-radius: 99px; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.power-state-dot { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 0.7rem; - font-weight: 500; - margin-left: 6px; -} -.power-state-dot::before { - content: ''; - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} -.power-state-dot.running::before { background: #10b981; } -.power-state-dot.offline::before { background: #6b7280; } -.power-state-dot.starting::before { background: #f59e0b; animation: pulse-dot 1s ease-in-out infinite; } -.power-state-dot.stopping::before { background: #f59e0b; animation: pulse-dot 1s ease-in-out infinite; } - -@keyframes pulse-dot { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -.status-active { - background: rgba(16, 185, 129, 0.12); - color: var(--accent-green); -} - -.status-suspended { - background: rgba(239, 68, 68, 0.12); - color: var(--accent-red); -} - -.status-expired { - background: rgba(245, 158, 11, 0.12); - color: var(--accent-orange); -} - -.status-installing { - background: rgba(245, 158, 11, 0.12); - color: var(--accent-orange); -} - -.status-offline { - background: rgba(107, 114, 128, 0.12); - color: #9ca3af; -} - -.server-card-details { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.server-detail-tag { - font-size: 0.75rem; - color: var(--text-secondary); - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.06); - padding: 4px 10px; - border-radius: 4px; - font-family: 'JetBrains Mono', monospace; -} - -.server-card-actions { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border); - display: flex; - gap: 8px; -} - -/* ===== TABLE ===== */ -.table-container { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - overflow: hidden; -} - -table { - width: 100%; - border-collapse: collapse; -} - -thead th { - text-align: left; - padding: 14px 20px; - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); - background: rgba(255,255,255,0.02); - border-bottom: 1px solid var(--border); -} - -tbody td { - padding: 14px 20px; - font-size: 0.9rem; - border-bottom: 1px solid rgba(255,255,255,0.04); -} - -tbody tr:last-child td { - border-bottom: none; -} - -tbody tr:hover { - background: rgba(238, 129, 50, 0.04); -} - -/* ===== TABS ===== */ -.tabs { - position: relative; - display: flex; - gap: 4px; - margin-bottom: 24px; - border-bottom: 1px solid var(--border); - padding-bottom: 0; -} - -.tab { - background: none; - border: none; - color: var(--text-secondary); - padding: 10px 20px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - margin-bottom: -1px; - transition: color 0.15s; -} - -.tab:hover { - color: var(--text-primary); -} - -.tab.active { - color: var(--accent-1); -} - -.tab-indicator { - position: absolute; - bottom: -1px; - left: 0; - height: 2px; - background: var(--accent-1); - transition: left 0.2s ease, width 0.2s ease; - pointer-events: none; -} - -.tab-content { - animation: fadeIn 0.15s ease; -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -/* ===== ACTION CARDS ===== */ -.action-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-xl); - padding: 20px; - margin-bottom: 16px; -} - -.action-card-header { - display: flex; - align-items: flex-start; - gap: 14px; - margin-bottom: 16px; -} - -.action-card-header svg { - flex-shrink: 0; - margin-top: 2px; - color: var(--accent-1); -} - -.action-card-title { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 4px 0; -} - -.action-card-desc { - font-size: 0.85rem; - color: var(--text-muted); - line-height: 1.5; - margin: 0; -} - -/* ===== MODAL ===== */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.7); - backdrop-filter: blur(4px); - z-index: 200; - display: none; - align-items: center; - justify-content: center; - padding: 24px; -} - -.modal-overlay.open { - display: flex; -} - -.modal { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-xl); - padding: 32px; - width: 100%; - max-width: 520px; - max-height: 90vh; - overflow-y: auto; - box-shadow: 0 32px 80px rgba(0,0,0,0.6); -} - -.modal-title { - font-size: 1.3rem; - font-weight: 700; - margin-bottom: 24px; -} - -.modal-actions { - display: flex; - gap: 12px; - margin-top: 28px; -} - -/* ===== EMPTY STATE ===== */ -.empty-state { - text-align: center; - padding: 60px 24px; - background: var(--bg-card); - border: 1px dashed var(--border); - border-radius: var(--radius-lg); -} - -.empty-state-icon { - width: 56px; - height: 56px; - border-radius: 50%; - background: rgba(238, 129, 50, 0.1); - display: flex; - align-items: center; - justify-content: center; - margin: 0 auto 16px; - color: var(--accent-1); -} - -.empty-state-title { - font-size: 1.1rem; - font-weight: 700; - margin-bottom: 8px; -} - -.empty-state-desc { - font-size: 0.88rem; - color: var(--text-secondary); - margin-bottom: 20px; -} - -/* ===== SPINNER ===== */ -.spinner { - display: inline-block; - width: 20px; - height: 20px; - border: 2px solid rgba(255,255,255,0.2); - border-top-color: #fff; - border-radius: 50%; - animation: spin 0.6s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* ===== TOAST ===== */ -.toast-container { - position: fixed; - bottom: 24px; - right: 24px; - z-index: 300; - display: flex; - flex-direction: column; - gap: 8px; -} - -.toast { - padding: 14px 20px; - border-radius: var(--radius-sm); - font-size: 0.88rem; - font-weight: 500; - animation: slideUp 0.3s ease; - box-shadow: 0 8px 32px rgba(0,0,0,0.4); -} - -.toast-success { - background: rgba(16, 185, 129, 0.15); - border: 1px solid rgba(16, 185, 129, 0.3); - color: var(--accent-green); -} - -.toast-error { - background: rgba(239, 68, 68, 0.15); - border: 1px solid rgba(239, 68, 68, 0.3); - color: var(--accent-red); -} - -@keyframes slideUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} - -/* ===== NOTIFICATION PANEL ===== */ -.notif-panel { - position: fixed; - top: 0; - left: var(--sidebar-w); - width: 380px; - height: 100vh; - background: var(--bg-card); - border-right: 1px solid var(--border); - z-index: 99; - transform: translateX(-100%); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - flex-direction: column; - overflow: hidden; -} - -.notif-panel.open { - transform: translateX(0); -} - -.sidebar.collapsed ~ .notif-panel { - left: 60px; -} - -.notif-backdrop { - position: fixed; - inset: 0; - z-index: 50; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - opacity: 0; - pointer-events: none; - transition: opacity 0.3s ease; -} - -.notif-backdrop.open { - opacity: 1; - pointer-events: auto; -} - -.notif-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 20px 16px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} - -.notif-panel-header h3 { - font-size: 1.1rem; - font-weight: 700; - color: var(--text-primary); -} - -.notif-mark-all { - background: none; - border: none; - color: var(--accent-3); - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - padding: 4px 8px; - border-radius: var(--radius-sm); - transition: all var(--transition); -} - -.notif-mark-all:hover { - background: rgba(238, 129, 50, 0.1); -} - -.notif-panel-list { - flex: 1; - overflow-y: auto; - padding: 8px 0; -} - -.notif-empty { - text-align: center; - color: var(--text-muted); - padding: 48px 20px; - font-size: 0.9rem; -} - -.notif-item { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 14px 20px; - cursor: pointer; - transition: background var(--transition); - position: relative; -} - -.notif-item:hover { - background: rgba(238, 129, 50, 0.04); -} - -.notif-unread { - background: rgba(238, 129, 50, 0.06); -} - -.notif-unread:hover { - background: rgba(238, 129, 50, 0.1); -} - -.notif-item-icon { - width: 36px; - height: 36px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.notif-icon { - width: 18px; - height: 18px; -} - -.notif-item-icon.notif-success { - background: rgba(16, 185, 129, 0.15); - color: var(--accent-green); -} - -.notif-item-icon.notif-error { - background: rgba(239, 68, 68, 0.15); - color: var(--accent-red); -} - -.notif-item-icon.notif-warning { - background: rgba(245, 158, 11, 0.15); - color: var(--accent-orange); -} - -.notif-item-icon.notif-info { - background: rgba(6, 182, 212, 0.15); - color: var(--accent-cyan); -} - -.notif-item-body { - flex: 1; - min-width: 0; -} - -.notif-item-title { - font-size: 0.88rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 2px; -} - -.notif-item-msg { - font-size: 0.82rem; - color: var(--text-secondary); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.notif-item-time { - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 4px; -} - -.notif-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent-3); - flex-shrink: 0; - margin-top: 6px; -} - -.notif-badge { - position: absolute; - top: -6px; - right: -6px; - min-width: 18px; - height: 18px; - border-radius: 99px; - background: var(--accent-red); - color: #fff; - font-size: 0.65rem; - font-weight: 700; - display: none; - align-items: center; - justify-content: center; - padding: 0 5px; - line-height: 1; -} - -/* ===== RESPONSIVE ===== */ -.hamburger-toggle { - display: none; - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 8px; -} - -@media (max-width: 768px) { - .sidebar { - transform: translateX(-100%); - } - - .sidebar.open { - transform: translateX(0); - } - - .sidebar-resizer { - display: none; - } - - .main-content { - margin-left: 0; - padding: 20px; - } - - .hamburger-toggle { - display: flex; - align-items: center; - justify-content: center; - position: fixed; - top: 16px; - left: 16px; - z-index: 101; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px; - } - - .stat-grid { - grid-template-columns: 1fr; - } - - .server-grid { - grid-template-columns: 1fr; - } - - .form-row { - flex-direction: column; - gap: 0; - } - - .auth-card { - padding: 32px 24px; - } - - .table-container { - overflow-x: auto; - } -} - -/* ===== PYRODACTYL PAGE ===== */ -.ptero-grid { - max-width: 640px; -} - -.ptero-card-icon { - width: 56px; - height: 56px; - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 20px; - color: var(--accent-cyan); - background: rgba(34, 211, 238, 0.1); -} - -.ptero-card-title { - font-size: 1.4rem; - font-weight: 700; - margin-bottom: 12px; -} - -.ptero-card-desc { - color: var(--text-secondary); - line-height: 1.7; - margin-bottom: 24px; -} - -.ptero-info { - display: flex; - flex-direction: column; - gap: 14px; -} - -.ptero-info-item { - display: flex; - align-items: flex-start; - gap: 12px; - font-size: 0.9rem; - color: var(--text-secondary); - line-height: 1.5; -} - -.ptero-info-item svg { - flex-shrink: 0; - margin-top: 2px; - color: var(--accent-cyan); -} - -.ptero-info-item span { - flex: 1; -} - -.ptero-info-item strong { - color: var(--text-primary); -} - -/* ===== ACCOUNT PAGE ===== */ -.account-grid { - display: flex; - flex-direction: column; - gap: 24px; - max-width: 640px; -} - -.account-grid .card-title { - font-size: 1.1rem; - font-weight: 700; -} - -.account-menu-card { - transition: background var(--transition); -} - -.account-menu-card:hover { - background: var(--bg-secondary); -} - -.account-menu-item { - display: flex; - align-items: center; - gap: 16px; -} - -.account-menu-icon { - width: 44px; - height: 44px; - border-radius: var(--radius-sm); - background: var(--bg-secondary); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--accent-3); -} - -.account-menu-text { - flex: 1; - min-width: 0; -} - -.account-menu-title { - font-weight: 600; - font-size: 0.95rem; - color: var(--text-primary); -} - -.account-menu-desc { - font-size: 0.8rem; - color: var(--text-muted); - margin-top: 2px; -} - -/* ===== AVATAR UPLOAD ===== */ -.avatar-upload { - display: flex; - gap: 20px; - align-items: flex-start; - flex-wrap: wrap; -} - -.avatar-upload-preview { - width: 100px; - height: 100px; - border-radius: 50%; - background: var(--accent-1); - display: flex; - align-items: center; - justify-content: center; - font-size: 2.5rem; - font-weight: 700; - color: #fff; - flex-shrink: 0; - overflow: hidden; - position: relative; -} - -.avatar-upload-preview img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 50%; -} - -.avatar-upload-placeholder { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.avatar-upload-info { - flex: 1; - min-width: 200px; -} - -/* ===== SERVER EXPIRY ===== */ -.server-card-expiry { - display: flex; - gap: 6px; - font-size: 0.75rem; - color: var(--text-secondary); - padding: 8px 0 4px; - flex-wrap: wrap; -} - -.server-card-expiry.expired { - color: var(--accent-red); -} - -.server-card-expiry.expiring { - color: var(--accent-orange); -} - -/* ===== SERVER DETAIL PAGE ===== */ -.server-detail-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; - align-items: start; -} - - - -.detail-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.detail-item { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 8px; - border-bottom: 1px solid rgba(255,255,255,0.04); -} - -.detail-item:last-child { - border-bottom: none; - padding-bottom: 0; -} - -.detail-label { - font-size: 0.82rem; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - font-weight: 600; -} - -.detail-value { - font-size: 0.9rem; - color: var(--text-primary); - font-weight: 500; -} - -@media (max-width: 768px) { - .server-detail-grid { - grid-template-columns: 1fr; - } -} - -/* ===== RGPD / COOKIE CONSENT ===== */ -.cookie-banner { - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 9999; - background: var(--bg-card); - border-top: 1px solid var(--border); - padding: 20px 24px; - box-shadow: 0 -8px 40px rgba(0,0,0,0.5); - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - flex-wrap: wrap; - animation: slideUpFromBelow 0.4s ease; -} - -@keyframes slideUpFromBelow { - from { transform: translateY(100%); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -.cookie-banner-text { - flex: 1; - min-width: 280px; -} - -.cookie-banner-text p { - font-size: 0.88rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 4px; -} - -.cookie-banner-text a { - color: var(--accent-3); - text-decoration: underline; -} - -.cookie-banner-actions { - display: flex; - gap: 10px; - flex-shrink: 0; - flex-wrap: wrap; -} - -/* ===== RGPD CONSENT CHECKBOX ===== */ -.consent-group { - margin-bottom: 20px; - display: flex; - align-items: flex-start; - gap: 10px; -} - -.custom-checkbox { - display: inline-flex; - align-items: center; - justify-content: center; - position: relative; - cursor: pointer; - flex-shrink: 0; - margin-top: 2px; -} - -.custom-checkbox input[type="checkbox"] { - position: absolute; - opacity: 0; - width: 0; - height: 0; - pointer-events: none; -} - -.custom-checkbox .checkmark { - width: 20px; - height: 20px; - border: 2px solid var(--text-secondary); - border-radius: 4px; - background: transparent; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - flex-shrink: 0; - box-sizing: border-box; -} - -.custom-checkbox input[type="checkbox"]:checked ~ .checkmark { - background: var(--accent-1); - border-color: var(--accent-1); -} - -.custom-checkbox input[type="checkbox"]:checked ~ .checkmark::after { - content: ''; - display: block; - width: 5px; - height: 9px; - border: solid #fff; - border-width: 0 2px 2px 0; - transform: rotate(45deg); - margin-top: -1px; -} - -.custom-checkbox input[type="checkbox"]:focus-visible ~ .checkmark { - outline: 2px solid var(--accent-1); - outline-offset: 2px; -} - -.consent-group label { - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.5; - cursor: pointer; -} - -.consent-group label a { - color: var(--accent-3); - text-decoration: underline; -} - -.cap-modal-overlay { - position: fixed; - inset: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - background: rgba(0, 0, 0, 0.5); - animation: capFadeIn 0.2s ease; -} - -.cap-modal-overlay.cap-modal-fadeout { - animation: capFadeOut 0.3s ease forwards; -} - -.cap-modal { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 24px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); - animation: capScaleIn 0.2s ease; -} - -.cap-modal cap-widget { - display: block !important; - width: 300px !important; - min-width: unset !important; -} - -.cap-modal-overlay.cap-modal-fadeout .cap-modal { - animation: capScaleOut 0.3s ease forwards; -} - -@keyframes capFadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes capFadeOut { - from { opacity: 1; } - to { opacity: 0; } -} - -@keyframes capScaleIn { - from { transform: scale(0.9); opacity: 0; } - to { transform: scale(1); opacity: 1; } -} - -@keyframes capScaleOut { - from { transform: scale(1); opacity: 1; } - to { transform: scale(0.9); opacity: 0; } -} - -/* ===== ACTIVITY TIMELINE ===== */ -.activity-list { - display: flex; - flex-direction: column; - gap: 0; -} - -.activity-item { - display: flex; - align-items: flex-start; - gap: 14px; - padding: 14px 0; - border-bottom: 1px solid rgba(255,255,255,0.04); - position: relative; -} - -.activity-item:last-child { - border-bottom: none; -} - -.activity-icon { - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - font-size: 0.8rem; - position: relative; - z-index: 1; -} - -.activity-icon-server_created { background: rgba(16,185,129,0.15); color: var(--accent-green); } -.activity-icon-server_renewed { background: rgba(6,182,212,0.15); color: var(--accent-cyan); } -.activity-icon-server_renamed { background: rgba(238,129,50,0.15); color: var(--accent-1); } -.activity-icon-server_reinstalled { background: rgba(245,158,11,0.15); color: var(--accent-orange); } -.activity-icon-server_deleted { background: rgba(239,68,68,0.15); color: var(--accent-red); } -.activity-icon-account_registered { background: rgba(168,85,247,0.15); color: #a855f7; } -.activity-icon-password_changed { background: rgba(59,130,246,0.15); color: #3b82f6; } -.activity-icon-email_changed { background: rgba(59,130,246,0.15); color: #3b82f6; } -.activity-icon-account_deleted { background: rgba(239,68,68,0.15); color: var(--accent-red); } -.activity-icon-api_key_updated { background: rgba(236,201,75,0.15); color: #ecc94b; } -.activity-icon-avatar_updated { background: rgba(168,85,247,0.15); color: #a855f7; } -.activity-icon-admin_suspend { background: rgba(245,158,11,0.15); color: var(--accent-orange); } -.activity-icon-admin_unsuspend { background: rgba(16,185,129,0.15); color: var(--accent-green); } -.activity-icon-admin_renew_now { background: rgba(6,182,212,0.15); color: var(--accent-cyan); } - -.activity-content { - flex: 1; - min-width: 0; -} - -.activity-action { - font-size: 0.88rem; - font-weight: 600; - color: var(--text-primary); -} - -.activity-details { - font-size: 0.78rem; - color: var(--text-muted); - margin-top: 2px; -} - -.activity-time { - font-size: 0.72rem; - color: var(--text-muted); - white-space: nowrap; - padding-top: 2px; -} - -.activity-empty { - text-align: center; - padding: 32px 16px; - color: var(--text-muted); - font-size: 0.88rem; -} - -/* ===== LOG PAGE ===== */ -.log-pagination { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - padding: 16px 0; -} - -.log-pagination-info { - font-size: 0.82rem; - color: var(--text-muted); -} - -.log-pagination .btn-ghost[disabled] { - opacity: 0.4; - cursor: not-allowed; - pointer-events: none; -} - -/* ===== SEARCH & FILTERS ===== */ -.servers-toolbar { - display: flex; - gap: 12px; - margin-bottom: 20px; - flex-wrap: wrap; - align-items: center; -} - -.search-wrapper { - position: relative; - flex: 1; - min-width: 200px; -} - -.search-wrapper svg { - position: absolute; - left: 14px; - top: 50%; - transform: translateY(-50%); - color: var(--text-muted); - pointer-events: none; -} - -.search-wrapper input { - width: 100%; - padding: 10px 14px 10px 40px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-family: 'Schibsted Grotesk', sans-serif; - font-size: 0.9rem; - outline: none; - transition: all var(--transition); -} - -.search-wrapper input:focus { - border-color: var(--accent-1); - box-shadow: 0 0 0 3px rgba(238,129,50,0.15); -} - -.search-wrapper input::placeholder { - color: var(--text-muted); -} - -.filter-group { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.filter-btn { - padding: 8px 16px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 99px; - color: var(--text-secondary); - font-family: 'Schibsted Grotesk', sans-serif; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: all var(--transition); - white-space: nowrap; -} - -.filter-btn:hover { - border-color: var(--border-hover); - color: var(--text-primary); -} - -.filter-btn.active { - background: rgba(238,129,50,0.12); - border-color: var(--accent-1); - color: var(--accent-1); -} - - - -/* Resource gauges for server detail */ -.resource-gauges { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 20px; - margin-top: 8px; -} - -.resource-gauge { - text-align: center; - padding: 20px 12px; - background: rgba(255,255,255,0.02); - border: 1px solid var(--border); - border-radius: var(--radius-md); - position: relative; - overflow: hidden; -} - -.resource-gauge-value { - font-size: 1.8rem; - font-weight: 800; - letter-spacing: -0.03em; - margin-bottom: 4px; -} - -.resource-gauge-label { - font-size: 0.75rem; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 600; -} - -.resource-gauge-bar { - margin-top: 12px; - height: 4px; - background: rgba(255,255,255,0.06); - border-radius: 99px; - overflow: hidden; -} - -.resource-gauge-fill { - height: 100%; - border-radius: 99px; - transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); -} - -.resource-gauge-sub { - font-size: 0.68rem; - color: var(--text-muted); - margin-top: 6px; - font-family: 'JetBrains Mono', monospace; -} - -/* Account API key section */ -.api-key-input-group { - display: flex; - gap: 8px; - align-items: stretch; -} - -.api-key-input-group input { - flex: 1; -} - -@media (max-width: 768px) { - .servers-toolbar { - flex-direction: column; - align-items: stretch; - } - .filter-group { - justify-content: center; - } - .resource-gauges { - grid-template-columns: 1fr; - } -} - -/* ===== ADMIN PANEL ===== */ -.admin-layout { - min-height: 100vh; - display: flex; - flex-direction: column; - position: relative; -} - -.admin-navbar { - display: flex; - align-items: center; - padding: 0 24px; - height: 60px; - background: var(--bg-sidebar); - border-bottom: 1px solid var(--border); - position: sticky; - top: 0; - z-index: 100; - gap: 16px; -} - -.admin-navbar-left { - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; -} - -.admin-navbar-logo { - width: 28px; - height: 28px; - border-radius: 6px; -} - -.admin-navbar-brand { - font-size: 1rem; - font-weight: 800; - letter-spacing: -0.02em; - display: flex; - align-items: center; - gap: 6px; -} - -.admin-badge { - font-size: 0.6rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent-1); - border: 1px solid var(--accent-1); - padding: 2px 6px; - border-radius: 4px; -} - -.admin-navbar-center { - display: flex; - align-items: center; - gap: 4px; - flex: 1; - justify-content: center; -} - -.admin-nav-link { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: 0.88rem; - font-weight: 500; - text-decoration: none; - transition: all var(--transition); -} - -.admin-nav-link:hover { - color: var(--text-primary); - background: rgba(238, 129, 50, 0.08); -} - -.admin-nav-link.active { - color: var(--accent-1); - background: rgba(238, 129, 50, 0.12); -} - -.admin-navbar-right { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; -} - -.admin-navbar-user { - font-size: 0.85rem; - font-weight: 600; - color: var(--text-primary); -} - -.admin-content { - flex: 1; - padding: 32px; - position: relative; - z-index: 1; -} - -.admin-page { - display: none; -} - -.admin-page.active { - display: block; -} - -.admin-layout::before { - content: ''; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background-image: - linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); - background-size: 24px 24px; - mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); - -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); -} - -.admin-notice { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - margin-bottom: 24px; - background: rgba(238, 129, 50, 0.08); - border: 1px solid rgba(238, 129, 50, 0.2); - border-radius: var(--radius-sm); - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.5; -} - -.admin-notice svg { - flex-shrink: 0; - color: var(--accent-1); - opacity: 0.8; -} - -.admin-content > * { - position: relative; - z-index: 1; -} - -@media (max-width: 768px) { - .admin-navbar { - padding: 0 12px; - gap: 8px; - } - .admin-navbar-brand { - font-size: 0.85rem; - } - .admin-navbar-user { - display: none; - } - .admin-content { - padding: 20px; - } -} - -.date-tooltip { - cursor: help; -} -#admin-date-tooltip { - position: fixed; - z-index: 10000; - background: var(--bg-card); - color: var(--text-primary); - padding: 6px 12px; - border-radius: var(--radius-sm); - font-size: 0.85rem; - white-space: nowrap; - pointer-events: none; - opacity: 0; - box-shadow: 0 4px 12px rgba(0,0,0,0.4); - border: 1px solid var(--border); - transition: opacity 0.15s ease; -} -#admin-date-tooltip.visible { - opacity: 1; -} - - +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-primary: #000; + --bg-secondary: #1c1917; + --bg-tertiary: #292524; + --bg-card: #1c1917; + --bg-card-hover: #292524; + --bg-sidebar: #0d0d0d; + --border: rgba(238, 129, 50, 0.15); + --border-hover: rgba(238, 129, 50, 0.3); + --accent-1: #ee8132; + --accent-2: #ee8132; + --accent-3: #ee8132; + --accent-cyan: #06b6d4; + --accent-green: #059669; + --accent-red: #ef4444; + --accent-orange: #f59e0b; + --text-primary: #f5f5f4; + --text-secondary: #a8a29e; + --text-muted: #78716c; + --glow-primary: rgba(238, 129, 50, 0.15); + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1); + --sidebar-w: 260px; +} + +html { height: 100%; } + +body, body * { + font-family: 'Schibsted Grotesk', system-ui, sans-serif; + font-weight: 400; +} + +body { + height: 100%; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + overflow-x: hidden; +} + +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: var(--bg-primary); } +::-webkit-scrollbar-thumb { background: var(--accent-1); border-radius: 99px; } + +a { color: var(--accent-3); text-decoration: none; } +a:hover { color: var(--accent-1); } + +/* ===== AUTH PAGES ===== */ +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #000; + position: relative; + padding: 24px; +} + +.auth-page::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background-image: + linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); + -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); +} + +.auth-page::after { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 512px 512px; +} + +.auth-card { + position: relative; + z-index: 1; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 48px 40px; + width: 100%; + max-width: 440px; + box-shadow: 0 24px 80px rgba(0,0,0,0.5); +} + +.auth-logo { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.auth-logo img { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); +} + +.auth-logo-text { + font-size: 1.4rem; + font-weight: 800; + letter-spacing: -0.02em; +} + +.auth-logo-accent { color: var(--accent-3); } + +.auth-title { + font-size: 1.6rem; + font-weight: 700; + margin-bottom: 6px; +} + +.auth-subtitle { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 32px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: 'Schibsted Grotesk', sans-serif; + font-size: 0.95rem; + transition: all var(--transition); + outline: none; +} + +.form-group input:focus, .form-group select:focus, .form-group textarea:focus { + border-color: var(--accent-1); + box-shadow: 0 0 0 3px rgba(238, 129, 50, 0.15); +} + +.form-group input::placeholder { + color: var(--text-muted); +} + +.form-group select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; +} + +/* Custom Select */ +.custom-select { + position: relative; + width: 100%; +} + +.custom-select-trigger { + width: 100%; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: 'Schibsted Grotesk', sans-serif; + font-size: 0.95rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + transition: all var(--transition); + outline: none; + text-align: left; +} + +.custom-select-trigger:hover { + border-color: var(--border-hover); +} + +.custom-select-trigger:focus, +.custom-select.open .custom-select-trigger { + border-color: var(--accent-1); + box-shadow: 0 0 0 3px rgba(238, 129, 50, 0.15); +} + +.custom-select-arrow { + flex-shrink: 0; + color: var(--text-secondary); + transition: transform var(--transition); +} + +.custom-select.open .custom-select-arrow { + transform: rotate(180deg); +} + +.custom-select-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 12px 40px rgba(0,0,0,0.5); + z-index: 50; + max-height: 200px; + overflow-y: auto; + display: none; +} + +.custom-select.open .custom-select-dropdown { + display: block; +} + +.custom-select-option { + padding: 10px 16px 10px 20px; + font-size: 0.9rem; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + border-left: 2px solid transparent; +} + +.custom-select-option:hover { + background: rgba(238, 129, 50, 0.08); + color: var(--text-primary); + border-left-color: var(--accent-1); +} + +.custom-select-option:first-child { + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} + +.custom-select-option:last-child { + border-radius: 0 0 var(--radius-sm) var(--radius-sm); +} + +.custom-select-category { + padding: 14px 16px 6px; + font-size: 0.65rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent-1); + cursor: default; + border-top: 1px solid rgba(238, 129, 50, 0.12); + margin-top: 6px; +} + +.custom-select-category:first-child { + border-top: none; + margin-top: 0; + padding-top: 12px; +} + +.form-row { + display: flex; + gap: 12px; +} + + + +cap-widget { + display: block !important; + width: 100% !important; + min-width: 100% !important; + --cap-background: #1c1917; + --cap-color: #f5f5f4; + --cap-border-color: rgba(238, 129, 50, 0.15); + --cap-checkbox-border-radius: 8px; + --cap-border-radius: 8px; + --cap-checkbox-background: transparent; + --cap-spinner-color: #ee8132; + --cap-spinner-background-color: rgba(238, 129, 50, 0.1); + --cap-widget-width: 100%; +} + +.form-row .form-group { + flex: 1; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: 'Schibsted Grotesk', sans-serif; + font-size: 0.9rem; + font-weight: 600; + border-radius: var(--radius-md); + padding: 12px 22px; + border: none; + cursor: pointer; + text-decoration: none; + transition: all var(--transition); + white-space: nowrap; +} + +.btn-primary { + background: linear-gradient(180deg, #433b32 0%, #29241f 100%); + color: #fff; + border: 1px solid rgba(255, 237, 217, 0.2); +} + +.btn-primary:hover { + color: #fff; + transform: translateY(-2px); + border-color: rgba(238, 129, 50, 0.4); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + color: var(--text-primary); + border-color: var(--border-hover); + background: rgba(238, 129, 50, 0.06); +} + +.btn-danger { + background: linear-gradient(135deg, var(--accent-red), #dc2626); + color: #fff; + box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3); +} + +.btn-danger:hover { + color: #fff; + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(239, 68, 68, 0.45); +} + +.btn-warning { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: #fff; + box-shadow: 0 4px 20px rgba(245, 158, 11, 0.3); +} + +.btn-warning:hover { + color: #fff; + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(245, 158, 11, 0.45); +} + +.btn-warning:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-sm { + padding: 8px 16px; + font-size: 0.82rem; +} + +.btn-full { + width: 100%; + justify-content: center; +} + +.auth-footer { + text-align: center; + margin-top: 24px; + font-size: 0.88rem; + color: var(--text-secondary); +} + +.auth-footer a { + color: var(--accent-3); + font-weight: 600; +} + +.auth-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--accent-red); + padding: 12px 16px; + border-radius: var(--radius-sm); + font-size: 0.85rem; + margin-bottom: 20px; + display: none; +} + +.auth-error.show { + display: block; +} + +/* ===== DASHBOARD LAYOUT ===== */ +.dashboard-layout { + display: flex; + min-height: 100vh; + position: relative; +} + +.dashboard-layout::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); + -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); +} + +.dashboard-layout::after { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 512px 512px; +} + +.sidebar { + width: var(--sidebar-w); + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 100; + overflow: hidden; + transition: transform var(--transition); +} + +.sidebar.resizing { + transition: none; +} + +.sidebar-resizer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 5px; + cursor: col-resize; + z-index: 10; + background: transparent; + transition: background 0.15s; +} + +.sidebar-resizer:hover, +.sidebar.resizing .sidebar-resizer { + background: var(--accent-1); +} + +.sidebar.collapsed { + width: 60px; +} + +.sidebar.collapsed .sidebar-resizer { + display: none; +} + +.sidebar.collapsed .sidebar-logo { + justify-content: center; +} + +.sidebar.collapsed .sidebar-logo-text { + display: none; +} + +.sidebar.collapsed .sidebar-header { + padding: 16px 12px; + justify-content: center; +} + +.sidebar.collapsed .sidebar-nav { + padding: 12px 8px; +} + +.sidebar.collapsed .nav-section-label { + display: none; +} + +.sidebar.collapsed .nav-item { + font-size: 0; + justify-content: center; + padding: 10px 0; + gap: 0; +} + +.sidebar.collapsed .nav-indicator { + left: 8px; + width: calc(100% - 16px); +} + +.sidebar.collapsed .nav-item svg { + font-size: 1rem; + width: 20px; + height: 20px; +} + +.sidebar.collapsed .sidebar-footer { + border-top: none; + padding: 8px 0 0; + text-align: center; +} + +.sidebar.collapsed #logout-btn { + display: none !important; +} + +.sidebar.collapsed .user-info { + display: block !important; + padding: 0 !important; + text-align: center !important; +} + +.sidebar.collapsed #sidebar-user-info > div:first-child { + gap: 0; + display: inline-flex !important; + overflow: visible !important; +} + +.sidebar.collapsed #sidebar-user-info > div:first-child > div:last-child { + display: none; +} + +.sidebar.collapsed .user-name, +.sidebar.collapsed .user-email { + display: none; +} + +.sidebar.collapsed .sidebar-footer > div:not(.user-info) { + display: none; +} + +.sidebar.collapsed ~ .main-content { + margin-left: 60px; +} + +.sidebar-tooltip { + position: fixed; + z-index: 10000; + background: var(--bg-card); + color: var(--text-primary); + padding: 6px 12px; + border-radius: var(--radius-sm); + font-size: 0.85rem; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translateY(-50%); + transition: opacity 0.15s ease; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + border: 1px solid var(--border); +} + +.sidebar-tooltip.visible { + opacity: 1; +} + +.main-content { + position: relative; + z-index: 1; +} + +.sidebar-header { + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; +} + +.sidebar-logo img { + width: 28px; + height: 28px; + border-radius: 6px; +} + +.sidebar-logo-text { + font-size: 1.1rem; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.sidebar-nav { + flex: 1; + padding: 8px 12px; + overflow-y: auto; + position: relative; +} + +.nav-indicator { + position: absolute; + left: 12px; + width: calc(100% - 24px); + background: rgba(238, 129, 50, 0.12); + border-radius: var(--radius-sm); + border-left: 3px solid var(--accent-1); + transition: top var(--transition), height var(--transition), opacity var(--transition); + pointer-events: none; + z-index: 1; +} + +.nav-section-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + padding: 16px 12px 6px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: color var(--transition); + text-decoration: none; + margin-bottom: 2px; + position: relative; + z-index: 2; +} + +.nav-item:hover { + color: var(--text-primary); + background: rgba(238, 129, 50, 0.08); +} + +.nav-item.active { + color: var(--accent-1); +} + +.nav-item svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.sidebar-footer { + padding: 12px 12px 4px; + border-top: 1px solid var(--border); +} + +.user-info { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; +} + +.user-info > div:first-child { + min-width: 0; + overflow: hidden; +} +.user-info > div:first-child > div:last-child { + min-width: 0; + overflow: hidden; +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent-1); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.user-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-email { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.main-content { + flex: 1; + margin-left: var(--sidebar-w); + padding: 32px; + min-height: 100vh; +} + +/* ===== DASHBOARD PAGES ===== */ +.page { + display: none; +} + +.page.active { + display: block; +} + +.page-header { + margin-bottom: 32px; +} + +.page-title { + font-size: 1.8rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 6px; +} + +.page-subtitle { + font-size: 0.92rem; + color: var(--text-secondary); +} + +/* ===== CARDS ===== */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px; + transition: all var(--transition); +} + +.card:hover { + border-color: var(--border-hover); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.card-title { + font-size: 1rem; + font-weight: 700; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: linear-gradient(180deg, var(--accent-1), var(--accent-2)); + border-radius: 0 2px 2px 0; +} + +.stat-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + color: var(--accent-1); + background: rgba(238, 129, 50, 0.12); +} + +.stat-value { + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 4px; +} + +.stat-label { + font-size: 0.82rem; + color: var(--text-secondary); + font-weight: 500; +} + +.chart-container { + position: relative; + max-width: 320px; + margin: 0 auto; +} + +.chart-container canvas { + width: 100% !important; + height: auto !important; +} + +/* ===== SERVER LIST ===== */ +.server-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; +} + +.server-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + transition: all var(--transition); + cursor: pointer; +} + +.server-card:hover { + border-color: var(--border-hover); + background: var(--bg-card-hover); + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(0,0,0,0.3); +} + +.server-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 12px; +} + +.server-card-name { + font-size: 1rem; + font-weight: 700; + color: var(--text-primary); +} + +.server-card-status { + font-size: 0.7rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 99px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.power-state-dot { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.7rem; + font-weight: 500; + margin-left: 6px; +} +.power-state-dot::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.power-state-dot.running::before { background: #10b981; } +.power-state-dot.offline::before { background: #6b7280; } +.power-state-dot.starting::before { background: #f59e0b; animation: pulse-dot 1s ease-in-out infinite; } +.power-state-dot.stopping::before { background: #f59e0b; animation: pulse-dot 1s ease-in-out infinite; } + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.status-active { + background: rgba(16, 185, 129, 0.12); + color: var(--accent-green); +} + +.status-suspended { + background: rgba(239, 68, 68, 0.12); + color: var(--accent-red); +} + +.status-expired { + background: rgba(245, 158, 11, 0.12); + color: var(--accent-orange); +} + +.status-installing { + background: rgba(245, 158, 11, 0.12); + color: var(--accent-orange); +} + +.status-offline { + background: rgba(107, 114, 128, 0.12); + color: #9ca3af; +} + +.server-card-details { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.server-detail-tag { + font-size: 0.75rem; + color: var(--text-secondary); + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); + padding: 4px 10px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; +} + +.server-card-actions { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); + display: flex; + gap: 8px; +} + +/* ===== TABLE ===== */ +.table-container { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +table { + width: 100%; + border-collapse: collapse; +} + +thead th { + text-align: left; + padding: 14px 20px; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + background: rgba(255,255,255,0.02); + border-bottom: 1px solid var(--border); +} + +tbody td { + padding: 14px 20px; + font-size: 0.9rem; + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +tbody tr:last-child td { + border-bottom: none; +} + +tbody tr:hover { + background: rgba(238, 129, 50, 0.04); +} + +/* ===== TABS ===== */ +.tabs { + position: relative; + display: flex; + gap: 4px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.tab { + background: none; + border: none; + color: var(--text-secondary); + padding: 10px 20px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + margin-bottom: -1px; + transition: color 0.15s; +} + +.tab:hover { + color: var(--text-primary); +} + +.tab.active { + color: var(--accent-1); +} + +.tab-indicator { + position: absolute; + bottom: -1px; + left: 0; + height: 2px; + background: var(--accent-1); + transition: left 0.2s ease, width 0.2s ease; + pointer-events: none; +} + +.tab-content { + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ===== ACTION CARDS ===== */ +.action-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 20px; + margin-bottom: 16px; +} + +.action-card-header { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 16px; +} + +.action-card-header svg { + flex-shrink: 0; + margin-top: 2px; + color: var(--accent-1); +} + +.action-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px 0; +} + +.action-card-desc { + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.5; + margin: 0; +} + +/* ===== MODAL ===== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + z-index: 200; + display: none; + align-items: center; + justify-content: center; + padding: 24px; +} + +.modal-overlay.open { + display: flex; +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 32px; + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 32px 80px rgba(0,0,0,0.6); +} + +.modal-title { + font-size: 1.3rem; + font-weight: 700; + margin-bottom: 24px; +} + +.modal-actions { + display: flex; + gap: 12px; + margin-top: 28px; +} + +/* ===== EMPTY STATE ===== */ +.empty-state { + text-align: center; + padding: 60px 24px; + background: var(--bg-card); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); +} + +.empty-state-icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: rgba(238, 129, 50, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 16px; + color: var(--accent-1); +} + +.empty-state-title { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 8px; +} + +.empty-state-desc { + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 20px; +} + +/* ===== SPINNER ===== */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(255,255,255,0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== TOAST ===== */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 300; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + padding: 14px 20px; + border-radius: var(--radius-sm); + font-size: 0.88rem; + font-weight: 500; + animation: slideUp 0.3s ease; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} + +.toast-success { + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.3); + color: var(--accent-green); +} + +.toast-error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--accent-red); +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== NOTIFICATION PANEL ===== */ +.notif-panel { + position: fixed; + top: 0; + left: var(--sidebar-w); + width: 380px; + height: 100vh; + background: var(--bg-card); + border-right: 1px solid var(--border); + z-index: 99; + transform: translateX(-100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.notif-panel.open { + transform: translateX(0); +} + +.sidebar.collapsed ~ .notif-panel { + left: 60px; +} + +.notif-backdrop { + position: fixed; + inset: 0; + z-index: 50; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.notif-backdrop.open { + opacity: 1; + pointer-events: auto; +} + +.notif-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.notif-panel-header h3 { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); +} + +.notif-mark-all { + background: none; + border: none; + color: var(--accent-3); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: all var(--transition); +} + +.notif-mark-all:hover { + background: rgba(238, 129, 50, 0.1); +} + +.notif-panel-list { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.notif-empty { + text-align: center; + color: var(--text-muted); + padding: 48px 20px; + font-size: 0.9rem; +} + +.notif-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 20px; + cursor: pointer; + transition: background var(--transition); + position: relative; +} + +.notif-item:hover { + background: rgba(238, 129, 50, 0.04); +} + +.notif-unread { + background: rgba(238, 129, 50, 0.06); +} + +.notif-unread:hover { + background: rgba(238, 129, 50, 0.1); +} + +.notif-item-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.notif-icon { + width: 18px; + height: 18px; +} + +.notif-item-icon.notif-success { + background: rgba(16, 185, 129, 0.15); + color: var(--accent-green); +} + +.notif-item-icon.notif-error { + background: rgba(239, 68, 68, 0.15); + color: var(--accent-red); +} + +.notif-item-icon.notif-warning { + background: rgba(245, 158, 11, 0.15); + color: var(--accent-orange); +} + +.notif-item-icon.notif-info { + background: rgba(6, 182, 212, 0.15); + color: var(--accent-cyan); +} + +.notif-item-body { + flex: 1; + min-width: 0; +} + +.notif-item-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; +} + +.notif-item-msg { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.notif-item-time { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; +} + +.notif-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-3); + flex-shrink: 0; + margin-top: 6px; +} + +.notif-view-modal { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.notif-view-modal.open { + opacity: 1; + pointer-events: auto; +} + +.notif-view-modal-content { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 0; + max-width: 500px; + width: 90%; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + animation: notifModalIn 0.2s ease; +} + +@keyframes notifModalIn { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.notif-view-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 0; +} + +.notif-view-modal-header h3 { + font-size: 1.05rem; + font-weight: 700; + color: var(--text-primary); + margin: 0; + flex: 1; +} + +.notif-view-modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition); + flex-shrink: 0; + margin-left: 12px; +} + +.notif-view-modal-close:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); +} + +.notif-view-modal-body { + padding: 16px 24px 24px; + overflow-y: auto; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.notif-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 18px; + height: 18px; + border-radius: 99px; + background: var(--accent-red); + color: #fff; + font-size: 0.65rem; + font-weight: 700; + display: none; + align-items: center; + justify-content: center; + padding: 0 5px; + line-height: 1; +} + +/* ===== RESPONSIVE ===== */ +.hamburger-toggle { + display: none; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-resizer { + display: none; + } + + .main-content { + margin-left: 0; + padding: 20px; + } + + .hamburger-toggle { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + top: 16px; + left: 16px; + z-index: 101; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px; + } + + .stat-grid { + grid-template-columns: 1fr; + } + + .server-grid { + grid-template-columns: 1fr; + } + + .form-row { + flex-direction: column; + gap: 0; + } + + .auth-card { + padding: 32px 24px; + } + + .table-container { + overflow-x: auto; + } +} + +/* ===== PYRODACTYL PAGE ===== */ +.ptero-grid { + max-width: 640px; +} + +.ptero-card-icon { + width: 56px; + height: 56px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + color: var(--accent-cyan); + background: rgba(34, 211, 238, 0.1); +} + +.ptero-card-title { + font-size: 1.4rem; + font-weight: 700; + margin-bottom: 12px; +} + +.ptero-card-desc { + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 24px; +} + +.ptero-info { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ptero-info-item { + display: flex; + align-items: flex-start; + gap: 12px; + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.ptero-info-item svg { + flex-shrink: 0; + margin-top: 2px; + color: var(--accent-cyan); +} + +.ptero-info-item span { + flex: 1; +} + +.ptero-info-item strong { + color: var(--text-primary); +} + +/* ===== ACCOUNT PAGE ===== */ +.account-grid { + display: flex; + flex-direction: column; + gap: 24px; + max-width: 640px; +} + +.account-grid .card-title { + font-size: 1.1rem; + font-weight: 700; +} + +.account-menu-card { + transition: background var(--transition); +} + +.account-menu-card:hover { + background: var(--bg-secondary); +} + +.account-menu-item { + display: flex; + align-items: center; + gap: 16px; +} + +.account-menu-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--accent-3); +} + +.account-menu-text { + flex: 1; + min-width: 0; +} + +.account-menu-title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary); +} + +.account-menu-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 2px; +} + +/* ===== AVATAR UPLOAD ===== */ +.avatar-upload { + display: flex; + gap: 20px; + align-items: flex-start; + flex-wrap: wrap; +} + +.avatar-upload-preview { + width: 100px; + height: 100px; + border-radius: 50%; + background: var(--accent-1); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + font-weight: 700; + color: #fff; + flex-shrink: 0; + overflow: hidden; + position: relative; +} + +.avatar-upload-preview img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.avatar-upload-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-upload-info { + flex: 1; + min-width: 200px; +} + +/* ===== SERVER EXPIRY ===== */ +.server-card-expiry { + display: flex; + gap: 6px; + font-size: 0.75rem; + color: var(--text-secondary); + padding: 8px 0 4px; + flex-wrap: wrap; +} + +.server-card-expiry.expired { + color: var(--accent-red); +} + +.server-card-expiry.expiring { + color: var(--accent-orange); +} + +/* ===== SERVER DETAIL PAGE ===== */ +.server-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} + + + +.detail-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.detail-item { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +.detail-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.detail-label { + font-size: 0.82rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.detail-value { + font-size: 0.9rem; + color: var(--text-primary); + font-weight: 500; +} + +@media (max-width: 768px) { + .server-detail-grid { + grid-template-columns: 1fr; + } +} + +/* ===== RGPD / COOKIE CONSENT ===== */ +.cookie-banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 9999; + background: var(--bg-card); + border-top: 1px solid var(--border); + padding: 20px 24px; + box-shadow: 0 -8px 40px rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; + animation: slideUpFromBelow 0.4s ease; +} + +@keyframes slideUpFromBelow { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.cookie-banner-text { + flex: 1; + min-width: 280px; +} + +.cookie-banner-text p { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 4px; +} + +.cookie-banner-text a { + color: var(--accent-3); + text-decoration: underline; +} + +.cookie-banner-actions { + display: flex; + gap: 10px; + flex-shrink: 0; + flex-wrap: wrap; +} + +/* ===== RGPD CONSENT CHECKBOX ===== */ +.consent-group { + margin-bottom: 20px; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.custom-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + cursor: pointer; + flex-shrink: 0; + margin-top: 2px; +} + +.custom-checkbox input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.custom-checkbox .checkmark { + width: 20px; + height: 20px; + border: 2px solid var(--text-secondary); + border-radius: 4px; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + box-sizing: border-box; +} + +.custom-checkbox input[type="checkbox"]:checked ~ .checkmark { + background: var(--accent-1); + border-color: var(--accent-1); +} + +.custom-checkbox input[type="checkbox"]:checked ~ .checkmark::after { + content: ''; + display: block; + width: 5px; + height: 9px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-top: -1px; +} + +.custom-checkbox input[type="checkbox"]:focus-visible ~ .checkmark { + outline: 2px solid var(--accent-1); + outline-offset: 2px; +} + +.consent-group label { + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.5; + cursor: pointer; +} + +.consent-group label a { + color: var(--accent-3); + text-decoration: underline; +} + +.cap-modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.5); + animation: capFadeIn 0.2s ease; +} + +.cap-modal-overlay.cap-modal-fadeout { + animation: capFadeOut 0.3s ease forwards; +} + +.cap-modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + animation: capScaleIn 0.2s ease; +} + +.cap-modal cap-widget { + display: block !important; + width: 300px !important; + min-width: unset !important; +} + +.cap-modal-overlay.cap-modal-fadeout .cap-modal { + animation: capScaleOut 0.3s ease forwards; +} + +@keyframes capFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes capFadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes capScaleIn { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@keyframes capScaleOut { + from { transform: scale(1); opacity: 1; } + to { transform: scale(0.9); opacity: 0; } +} + +/* ===== ACTIVITY TIMELINE ===== */ +.activity-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.activity-item { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); + position: relative; +} + +.activity-item:last-child { + border-bottom: none; +} + +.activity-icon { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 0.8rem; + position: relative; + z-index: 1; +} + +.activity-icon-server_created { background: rgba(16,185,129,0.15); color: var(--accent-green); } +.activity-icon-server_renewed { background: rgba(6,182,212,0.15); color: var(--accent-cyan); } +.activity-icon-server_renamed { background: rgba(238,129,50,0.15); color: var(--accent-1); } +.activity-icon-server_reinstalled { background: rgba(245,158,11,0.15); color: var(--accent-orange); } +.activity-icon-server_deleted { background: rgba(239,68,68,0.15); color: var(--accent-red); } +.activity-icon-account_registered { background: rgba(168,85,247,0.15); color: #a855f7; } +.activity-icon-password_changed { background: rgba(59,130,246,0.15); color: #3b82f6; } +.activity-icon-email_changed { background: rgba(59,130,246,0.15); color: #3b82f6; } +.activity-icon-account_deleted { background: rgba(239,68,68,0.15); color: var(--accent-red); } +.activity-icon-api_key_updated { background: rgba(236,201,75,0.15); color: #ecc94b; } +.activity-icon-avatar_updated { background: rgba(168,85,247,0.15); color: #a855f7; } +.activity-icon-admin_suspend { background: rgba(245,158,11,0.15); color: var(--accent-orange); } +.activity-icon-admin_unsuspend { background: rgba(16,185,129,0.15); color: var(--accent-green); } +.activity-icon-admin_renew_now { background: rgba(6,182,212,0.15); color: var(--accent-cyan); } + +.activity-content { + flex: 1; + min-width: 0; +} + +.activity-action { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); +} + +.activity-details { + font-size: 0.78rem; + color: var(--text-muted); + margin-top: 2px; +} + +.activity-time { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + padding-top: 2px; +} + +.activity-empty { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 0.88rem; +} + +/* ===== LOG PAGE ===== */ +.log-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px 0; +} + +.log-pagination-info { + font-size: 0.82rem; + color: var(--text-muted); +} + +.log-pagination .btn-ghost[disabled] { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +/* ===== SEARCH & FILTERS ===== */ +.servers-toolbar { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; + align-items: center; +} + +.search-wrapper { + position: relative; + flex: 1; + min-width: 200px; +} + +.search-wrapper svg { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; +} + +.search-wrapper input { + width: 100%; + padding: 10px 14px 10px 40px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: 'Schibsted Grotesk', sans-serif; + font-size: 0.9rem; + outline: none; + transition: all var(--transition); +} + +.search-wrapper input:focus { + border-color: var(--accent-1); + box-shadow: 0 0 0 3px rgba(238,129,50,0.15); +} + +.search-wrapper input::placeholder { + color: var(--text-muted); +} + +.filter-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.filter-btn { + padding: 8px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 99px; + color: var(--text-secondary); + font-family: 'Schibsted Grotesk', sans-serif; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.filter-btn:hover { + border-color: var(--border-hover); + color: var(--text-primary); +} + +.filter-btn.active { + background: rgba(238,129,50,0.12); + border-color: var(--accent-1); + color: var(--accent-1); +} + + + +/* Resource gauges for server detail */ +.resource-gauges { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 8px; +} + +.resource-gauge { + text-align: center; + padding: 20px 12px; + background: rgba(255,255,255,0.02); + border: 1px solid var(--border); + border-radius: var(--radius-md); + position: relative; + overflow: hidden; +} + +.resource-gauge-value { + font-size: 1.8rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 4px; +} + +.resource-gauge-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.resource-gauge-bar { + margin-top: 12px; + height: 4px; + background: rgba(255,255,255,0.06); + border-radius: 99px; + overflow: hidden; +} + +.resource-gauge-fill { + height: 100%; + border-radius: 99px; + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); +} + +.resource-gauge-sub { + font-size: 0.68rem; + color: var(--text-muted); + margin-top: 6px; + font-family: 'JetBrains Mono', monospace; +} + +/* Account API key section */ +.api-key-input-group { + display: flex; + gap: 8px; + align-items: stretch; +} + +.api-key-input-group input { + flex: 1; +} + +@media (max-width: 768px) { + .servers-toolbar { + flex-direction: column; + align-items: stretch; + } + .filter-group { + justify-content: center; + } + .resource-gauges { + grid-template-columns: 1fr; + } +} + +/* ===== ADMIN PANEL ===== */ +.admin-layout { + min-height: 100vh; + display: flex; + flex-direction: column; + position: relative; +} + +.admin-navbar { + display: flex; + align-items: center; + padding: 0 24px; + height: 60px; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + gap: 16px; +} + +.admin-navbar-left { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.admin-navbar-logo { + width: 28px; + height: 28px; + border-radius: 6px; +} + +.admin-navbar-brand { + font-size: 1rem; + font-weight: 800; + letter-spacing: -0.02em; + display: flex; + align-items: center; + gap: 6px; +} + +.admin-badge { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent-1); + border: 1px solid var(--accent-1); + padding: 2px 6px; + border-radius: 4px; +} + +.admin-navbar-center { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + justify-content: center; +} + +.admin-nav-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.88rem; + font-weight: 500; + text-decoration: none; + transition: all var(--transition); +} + +.admin-nav-link:hover { + color: var(--text-primary); + background: rgba(238, 129, 50, 0.08); +} + +.admin-nav-link.active { + color: var(--accent-1); + background: rgba(238, 129, 50, 0.12); +} + +.admin-navbar-right { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.admin-navbar-user { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); +} + +.admin-content { + flex: 1; + padding: 32px; + position: relative; + z-index: 1; +} + +.admin-page { + display: none; +} + +.admin-page.active { + display: block; +} + +.admin-layout::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); + -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); +} + +.admin-notice { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + margin-bottom: 24px; + background: rgba(238, 129, 50, 0.08); + border: 1px solid rgba(238, 129, 50, 0.2); + border-radius: var(--radius-sm); + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.admin-notice svg { + flex-shrink: 0; + color: var(--accent-1); + opacity: 0.8; +} + +.admin-content > * { + position: relative; + z-index: 1; +} + +@media (max-width: 768px) { + .admin-navbar { + padding: 0 12px; + gap: 8px; + } + .admin-navbar-brand { + font-size: 0.85rem; + } + .admin-navbar-user { + display: none; + } + .admin-content { + padding: 20px; + } +} + +/* โ”€โ”€โ”€ Create Wizard โ”€โ”€โ”€ */ +.wizard-progress { + display: flex; + gap: 4px; + max-width: 600px; + background: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: 8px; +} + +.wizard-step-indicator { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + padding: 8px 12px; + border-radius: var(--radius-md); + background: transparent; + transition: var(--transition); + opacity: 0.5; +} + +.wizard-step-indicator.active { + background: var(--accent-1); + opacity: 1; +} + +.wizard-step-indicator.active .wizard-step-circle { + background: rgba(255,255,255,0.2); +} + +.wizard-step-indicator.completed { + opacity: 0.8; +} + +.wizard-step-indicator.completed .wizard-step-circle { + background: var(--accent-green); +} + +.wizard-step-circle { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + flex-shrink: 0; +} + +.wizard-step-label { + font-size: 0.82rem; + font-weight: 600; + white-space: nowrap; +} + +.wizard-content { + width: 100%; +} + +.wizard-content-wrapper { + overflow: visible; + position: relative; +} + +.wizard-content.slide-left { + animation: slideLeft 0.3s ease; +} + +.wizard-content.slide-right { + animation: slideRight 0.3s ease; +} + +@keyframes slideLeft { + from { transform: translateX(20px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes slideRight { + from { transform: translateX(-20px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.wizard-progress-track { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent-1); + border-radius: calc(var(--radius-lg) - 4px); + transition: transform 0.3s var(--transition); +} +.wizard-step-title { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 4px; + color: var(--text-primary); +} + +.wizard-step-desc { + color: var(--text-secondary); + font-size: 0.9rem; + margin: 0 0 24px; +} + +.wizard-actions { + display: flex; + gap: 12px; + margin-top: 28px; +} + +#wizard-next-btn { + margin-left: auto; +} + +.wizard-subsection { + border-top: 1px solid var(--border); + padding-top: 20px; +} + +/* Nest Cards Grid */ +.nest-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; +} + +.nest-card { + background: var(--bg-secondary); + border: 2px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px 20px; + cursor: pointer; + transition: var(--transition); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.nest-card:hover { + border-color: var(--accent-1); + background: var(--bg-tertiary); + transform: translateY(-2px); +} + +.nest-card.selected { + border-color: var(--accent-1); + background: color-mix(in srgb, var(--accent-1) 10%, var(--bg-secondary)); + box-shadow: 0 0 0 1px var(--accent-1); +} + +.nest-card-logo { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; +} + +.nest-card-logo img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + filter: grayscale(100%); +} + +.nest-card-name { + font-weight: 700; + font-size: 1rem; + color: var(--text-primary); +} + +.nest-card-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Egg Cards Grid */ +.egg-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.egg-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--bg-secondary); + border: 2px solid var(--border); + border-radius: var(--radius-md); + padding: 14px 16px; + cursor: pointer; + transition: var(--transition); +} + +.egg-card:hover { + border-color: var(--accent-1); + background: var(--bg-tertiary); +} + +.egg-card.selected { + border-color: var(--accent-1); + background: color-mix(in srgb, var(--accent-1) 10%, var(--bg-secondary)); + box-shadow: 0 0 0 1px var(--accent-1); +} + +.egg-card-logo { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.egg-card-logo img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + filter: grayscale(100%); +} + +.egg-card-info { + min-width: 0; +} + +.egg-card-name { + font-weight: 600; + font-size: 0.92rem; + color: var(--text-primary); +} + +.egg-card-desc { + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.4; + margin-top: 2px; +} + +/* Docker Image Cards */ +/* Summary Card */ +.summary-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px 24px; + max-width: 480px; +} + +.summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.summary-row:last-child { + border-bottom: none; +} + +.summary-label { + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 500; +} + +.summary-value { + color: var(--text-primary); + font-weight: 600; + font-size: 0.92rem; + text-align: right; + max-width: 60%; + word-break: break-word; +} + +.date-tooltip { + cursor: help; +} +#admin-date-tooltip { + position: fixed; + z-index: 10000; + background: var(--bg-card); + color: var(--text-primary); + padding: 6px 12px; + border-radius: var(--radius-sm); + font-size: 0.85rem; + white-space: nowrap; + pointer-events: none; + opacity: 0; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + border: 1px solid var(--border); + transition: opacity 0.15s ease; +} +#admin-date-tooltip.visible { + opacity: 1; +} + + diff --git a/public/index.html b/public/index.html index 495d3f5..f208569 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ ZeroHost Dashboard - + diff --git a/public/js/admin.js b/public/js/admin.js index 71f829a..41a54bb 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,5 +1,11 @@ function initIcons() { if (window.lucide) lucide.createIcons(); } +function escapeHtml(str) { + if (typeof str !== 'string') return ''; + const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return str.replace(/[&<>"']/g, m => map[m]); +} + const ADMIN_STORAGE_KEY = 'zh_admin_token'; const adminState = { @@ -115,7 +121,7 @@ function renderAdminLogin() {

Admin Panel

@@ -192,7 +198,7 @@ function renderAdminLayout() {
+
+
+ +
+

Send Notification to All Users

+

Send a message to every user's notification inbox

+
`; $a('#settings-eggs-entry')?.addEventListener('click', () => adminNavigateTo('settings/eggs')); + $a('#settings-notify-all-entry')?.addEventListener('click', () => showNotifyAllModal()); initIcons(); } @@ -1291,13 +1423,15 @@ async function renderAdminEggsSettings() { Local ID + Logo Name + Description Panel Nest ID Actions - Loading... + Loading... @@ -1318,10 +1452,12 @@ async function renderAdminEggsSettings() { tbody.innerHTML = data.nests.map(n => ahtml` ${n.id} + ${n.logo ? `` : 'โ€”'} ${n.name} + ${n.description || 'โ€”'} ${n.ptero_nest_id} - + @@ -1329,7 +1465,7 @@ async function renderAdminEggsSettings() { tbody.querySelectorAll('.btn-rename-nest').forEach(btn => { btn.addEventListener('click', () => { - showRenameNestModal(btn.dataset.id, btn.dataset.name); + showRenameNestModal(btn.dataset.id, btn.dataset.name, btn.dataset.logo, btn.dataset.description); }); }); tbody.querySelectorAll('.btn-delete-nest').forEach(btn => { @@ -1363,6 +1499,7 @@ async function renderAdminNestEggs(nestId) { ID + Logo Name Description Resources @@ -1370,7 +1507,7 @@ async function renderAdminNestEggs(nestId) { - Loading... + Loading... @@ -1382,7 +1519,7 @@ async function renderAdminNestEggs(nestId) { if (!tbody) return; if (data.eggs.length === 0) { - tbody.innerHTML = 'No eggs found in this nest.'; + tbody.innerHTML = 'No eggs found in this nest.'; return; } @@ -1394,6 +1531,7 @@ async function renderAdminNestEggs(nestId) { return ahtml` ${e.id} + ${res?.logo ? `` : 'โ€”'} ${e.name} ${e.description || 'โ€”'} ${resStr} @@ -1405,7 +1543,7 @@ async function renderAdminNestEggs(nestId) { }).join(''); } catch (err) { const tbody = $a('#admin-eggs-tbody'); - if (tbody) tbody.innerHTML = `Error: ${err.message}`; + if (tbody) tbody.innerHTML = `Error: ${err.message}`; } initIcons(); } @@ -1425,12 +1563,19 @@ async function renderAdminEggSettings(nestId, eggId) {

Egg Resources

Loading...

-
+

- Set custom resource limits for this egg. Leave empty to use defaults. + Set custom resource limits and a logo for this egg.

+
+ + + +
@@ -1459,6 +1604,11 @@ async function renderAdminEggSettings(nestId, eggId) { if (data.egg) nameEl.textContent = data.egg.name + ` (Egg #${eggId})`; if (data.resources) { + if (data.resources.logo != null) { + $a('#egg-logo').value = data.resources.logo; + const preview = $a('#egg-logo-preview'); + if (preview) { preview.style.display = 'block'; preview.querySelector('img').src = data.resources.logo; } + } if (data.resources.cpu_limit != null) $a('#egg-cpu').value = data.resources.cpu_limit; if (data.resources.memory_limit != null) $a('#egg-memory').value = data.resources.memory_limit; if (data.resources.disk_limit != null) $a('#egg-disk').value = data.resources.disk_limit; @@ -1468,6 +1618,18 @@ async function renderAdminEggSettings(nestId, eggId) { if (errEl) { errEl.textContent = err.message; errEl.classList.add('show'); } } + $a('#egg-logo')?.addEventListener('input', () => { + const preview = $a('#egg-logo-preview'); + const img = preview?.querySelector('img'); + const val = $a('#egg-logo').value; + if (val) { + if (preview) preview.style.display = 'block'; + if (img) img.src = val; + } else { + if (preview) preview.style.display = 'none'; + } + }); + $a('#admin-egg-resources-form')?.addEventListener('submit', async (e) => { e.preventDefault(); const btn = $a('#btn-save-egg-resources'); @@ -1476,6 +1638,7 @@ async function renderAdminEggSettings(nestId, eggId) { btn.disabled = true; btn.innerHTML = ' Saving...'; + const logo = $a('#egg-logo').value.trim() || null; const cpu = $a('#egg-cpu').value; const memory = $a('#egg-memory').value; const disk = $a('#egg-disk').value; @@ -1484,6 +1647,7 @@ async function renderAdminEggSettings(nestId, eggId) { await adminApi(`/settings/eggs/${nestId}/${eggId}`, { method: 'PUT', body: JSON.stringify({ + logo, cpu_limit: cpu !== '' ? parseInt(cpu, 10) : null, memory_limit: memory !== '' ? parseInt(memory, 10) : null, disk_limit: disk !== '' ? parseInt(disk, 10) : null, @@ -1505,8 +1669,10 @@ async function renderAdminEggSettings(nestId, eggId) { try { await adminApi(`/settings/eggs/${nestId}/${eggId}`, { method: 'PUT', - body: JSON.stringify({ cpu_limit: null, memory_limit: null, disk_limit: null }), + body: JSON.stringify({ logo: null, cpu_limit: null, memory_limit: null, disk_limit: null }), }); + $a('#egg-logo').value = ''; + $a('#egg-logo-preview').style.display = 'none'; $a('#egg-cpu').value = ''; $a('#egg-memory').value = ''; $a('#egg-disk').value = ''; @@ -1626,7 +1792,7 @@ async function showAddNestsModal() { try { await adminApi('/settings/nests', { method: 'POST', - body: JSON.stringify({ pteroNestId: parseInt(cb.value, 10) }), + body: JSON.stringify({ pteroNestId: parseInt(cb.value, 10), name: cb.dataset.name }), }); added++; } catch (err) { @@ -1656,19 +1822,30 @@ async function showAddNestsModal() { } } -// โ”€โ”€โ”€ Rename Nest Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function showRenameNestModal(nestId, currentName) { +// โ”€โ”€โ”€ Edit Nest Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function showRenameNestModal(nestId, currentName, currentLogo, currentDescription) { const content = $a('#admin-modal-content'); const overlay = $a('#admin-modal-overlay'); if (!content || !overlay) return; content.innerHTML = ahtml`
-

Rename Nest

+

Edit Nest

+
+ + + +
+
+ + +
@@ -1678,8 +1855,22 @@ function showRenameNestModal(nestId, currentName) { `; overlay.style.display = 'flex'; + const logoInput = $a('#modal-edit-nest-logo'); + logoInput?.addEventListener('input', () => { + const preview = $a('#modal-nest-logo-preview'); + const img = preview?.querySelector('img'); + if (logoInput.value) { + if (preview) preview.style.display = 'block'; + if (img) img.src = logoInput.value; + } else { + if (preview) preview.style.display = 'none'; + } + }); + $a('#btn-confirm-rename-nest')?.addEventListener('click', async () => { const name = $a('#modal-rename-nest-name').value.trim(); + const logo = $a('#modal-edit-nest-logo').value.trim() || null; + const description = $a('#modal-edit-nest-description').value.trim() || null; if (!name) { const err = $a('#rename-nest-error'); if (err) { err.textContent = 'Name is required'; err.style.display = 'block'; } @@ -1693,7 +1884,7 @@ function showRenameNestModal(nestId, currentName) { try { await adminApi(`/settings/nests/${nestId}`, { method: 'PUT', - body: JSON.stringify({ name }), + body: JSON.stringify({ name, logo, description }), }); closeAdminModal(); renderAdminEggsSettings(); @@ -1774,10 +1965,24 @@ window.addEventListener('popstate', () => { $a('#admin-page-server-detail')?.classList.add('active'); $a('#admin-page-servers')?.classList.remove('active'); renderAdminServerDetail(pid); + const tab = pathParts[2]; + if (tab) { + setTimeout(() => { + const tabBtn = document.querySelector('#admin-server-tabs .tab[data-tab="' + tab + '"]'); + if (tabBtn) tabBtn.click(); + }, 50); + } } else if (basePage === 'user' && param) { const uid = parseInt(param, 10); $a('#admin-page-user-detail')?.classList.add('active'); renderAdminUserDetail(uid); + const tab = pathParts[2]; + if (tab) { + setTimeout(() => { + const tabBtn = document.querySelector('#admin-user-tabs .tab[data-tab="' + tab + '"]'); + if (tabBtn) tabBtn.click(); + }, 50); + } } else if (basePage === 'users') { adminNavigateTo('users'); } else if (basePage === 'dashboard' || !basePage || basePage === 'login') { diff --git a/public/js/app.js b/public/js/app.js index f355db2..07bf82a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,2705 +1,3070 @@ -function initIcons() { if (window.lucide) lucide.createIcons(); } - -const API_BASE = window.location.origin; - -const state = { - user: null, - token: localStorage.getItem('zh_token'), - currentPage: 'overview', - servers: [], - rgpdConsent: JSON.parse(localStorage.getItem('zh_rgpd_consent') || 'null'), - serverDetailTab: 'info', - notifications: [], - unreadCount: 0, - notifPanelOpen: false, -}; - -function setRgpdConsent(preferences) { - state.rgpdConsent = preferences; - localStorage.setItem('zh_rgpd_consent', JSON.stringify(preferences)); -} - -function renderCookieBanner() { - if (state.rgpdConsent) return; - const existing = document.getElementById('cookie-consent-banner'); - if (existing) return; - const banner = document.createElement('div'); - banner.id = 'cookie-consent-banner'; - banner.className = 'cookie-banner'; - banner.innerHTML = ` - - - `; - document.body.appendChild(banner); - - document.getElementById('cookie-essential-btn').addEventListener('click', () => { - setRgpdConsent({ essential: true, analytics: false, timestamp: new Date().toISOString() }); - banner.remove(); - }); - document.getElementById('cookie-accept-all-btn').addEventListener('click', () => { - setRgpdConsent({ essential: true, analytics: true, timestamp: new Date().toISOString() }); - banner.remove(); - }); -} - -const PTERO_URL = 'https://panel.zero-host.org'; - -function openPyrodactylPanel(serverIdentifier) { - const url = `${PTERO_URL}${serverIdentifier ? '/server/' + serverIdentifier : ''}`; - window.open(url, '_blank'); -} - -async function sendPowerCommand(identifier, signal, event) { - const btn = event?.target; - if (btn) { - btn.disabled = true; - btn.innerHTML = ''; - } - try { - await api(`/servers/power/${identifier}`, { method: 'POST', body: JSON.stringify({ signal }) }); - } catch (err) { - const overlay = $('#modal-overlay'); - const content = $('#modal-content'); - content.innerHTML = html` - -
- -

Failed to send ${signal} command:

-

${err.message}

-
- - `; - overlay.classList.add('open'); - } finally { - if (btn) { - btn.disabled = false; - btn.textContent = signal.charAt(0).toUpperCase() + signal.slice(1); - } - } -} - -function $(sel) { return document.querySelector(sel); } -function md5(s) { - function F(x,y,z) { return (x & y) | (~x & z); } - function G(x,y,z) { return (x & z) | (y & ~z); } - function H(x,y,z) { return x ^ y ^ z; } - function I(x,y,z) { return y ^ (x | ~z); } - function rol(x,n) { return (x << n) | (x >>> (32 - n)); } - function add(x,y) { return (x + y) >>> 0; } - function toHex(n) { let h=''; for(let i=0;i<4;i++) h+=((n>>(i*8))&0xFF).toString(16).padStart(2,'0'); return h; } - const T = new Array(64); - for(let i=1;i<=64;i++) T[i-1] = Math.floor(Math.abs(Math.sin(i)) * 0x100000000) >>> 0; - const s_ = [7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21]; - const S = (function(){ const r=[]; for(let i=0;i<64;i++) r[i]=s_[(i>>3<<2)+(i%4)]; return r; })(); - const K = [0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3]; - s = unescape(encodeURIComponent(s)); - const len = s.length; - const msg = []; for(let i=0;i>(8-(i%2)*8))&0xFF); - const bitLen = len * 8; - msg.push(0x80); - while((msg.length+8)%64!=0) msg.push(0); - for(let i=0;i<8;i++) msg.push((bitLen>>>(i*8))&0xFF); - let h0=0x67452301,h1=0xEFCDAB89,h2=0x98BADCFE,h3=0x10325476; - for(let i=0;i acc + str + (values[i] ?? ''), ''); -} - -async function api(path, options = {}) { - const headers = { 'Content-Type': 'application/json', ...options.headers }; - if (state.token) { - headers['Authorization'] = `Bearer ${state.token}`; - } - let res; - try { - res = await fetch(`${API_BASE}/api${path}`, { ...options, headers }); - } catch { - throw new Error('Unable to reach the server. Please check your connection and try again.'); - } - const text = await res.text(); - let data; - try { - data = JSON.parse(text); - } catch { - throw new Error('Server error: please try again in a few moments.'); - } - if (res.status === 403 && data.error === 'Invalid or expired token') { - state.token = null; - state.user = null; - localStorage.removeItem('zh_token'); - localStorage.removeItem('zh_user'); - navigateTo('login'); - throw new Error('Session expired. Please sign in again.'); - } - if (!res.ok) throw new Error(data.error || 'Request failed'); - return data; -} - -function showToast(message, type = 'success') { - const container = $('#toast-container') || (() => { - const el = document.createElement('div'); - el.id = 'toast-container'; - el.className = 'toast-container'; - document.body.appendChild(el); - return el; - })(); - - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.textContent = message; - container.appendChild(toast); - setTimeout(() => toast.remove(), 4000); -} - -function showError(form, message) { - const errorEl = form.querySelector('.auth-error'); - if (errorEl) { - errorEl.textContent = message; - errorEl.classList.add('show'); - } -} - -function hideError(form) { - const errorEl = form.querySelector('.auth-error'); - if (errorEl) errorEl.classList.remove('show'); -} - -const NOTIF_ICONS = { - success: '', - error: '', - warning: '', - info: '', -}; - -function timeAgo(dateStr) { - const diff = Date.now() - new Date(dateStr).getTime(); - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return minutes + 'm ago'; - const hours = Math.floor(minutes / 60); - if (hours < 24) return hours + 'h ago'; - const days = Math.floor(hours / 24); - if (days < 7) return days + 'd ago'; - return new Date(dateStr).toLocaleDateString(); -} - -async function fetchUnreadCount() { - try { - const data = await api('/notifications/unread-count'); - state.unreadCount = data.count; - updateNotifBadge(); - } catch {} -} - -async function fetchNotifications() { - try { - const data = await api('/notifications'); - state.notifications = data.notifications; - renderNotifications(); - } catch {} -} - -function renderNotifications() { - const list = $('#notif-panel-list'); - if (!list) return; - if (state.notifications.length === 0) { - list.innerHTML = '
No notifications yet
'; - return; - } - list.innerHTML = state.notifications.map(n => html` -
-
${NOTIF_ICONS[n.type] || NOTIF_ICONS.info}
-
-
${n.title}
-
${n.message}
-
${timeAgo(n.created_at)}
-
- ${n.is_read ? '' : '
'} -
- `).join(''); - - list.querySelectorAll('.notif-item.notif-unread').forEach(el => { - el.addEventListener('click', () => { - const id = parseInt(el.dataset.id, 10); - markAsRead(id); - }); - }); - initIcons(); -} - -async function markAsRead(id) { - try { - await api('/notifications/' + id + '/read', { method: 'PATCH' }); - const notif = state.notifications.find(n => n.id === id); - if (notif) notif.is_read = 1; - state.unreadCount = Math.max(0, state.unreadCount - 1); - updateNotifBadge(); - renderNotifications(); - } catch {} -} - -async function markAllAsRead() { - try { - await api('/notifications/read-all', { method: 'PATCH' }); - state.notifications.forEach(n => n.is_read = 1); - state.unreadCount = 0; - updateNotifBadge(); - renderNotifications(); - } catch {} -} - -function updateNotifBadge() { - const badge = $('#notif-badge'); - if (!badge) return; - if (state.unreadCount > 0) { - badge.textContent = state.unreadCount > 99 ? '99+' : state.unreadCount; - badge.style.display = 'flex'; - } else { - badge.style.display = 'none'; - } -} - -function toggleNotifPanel() { - if (state.notifPanelOpen) { - closeNotifPanel(); - } else { - openNotifPanel(); - } -} - -function openNotifPanel() { - state.notifPanelOpen = true; - $('#notif-panel').classList.add('open'); - $('#notif-backdrop').classList.add('open'); - document.body.style.overflow = 'hidden'; - - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); - $('#nav-notifications').classList.add('active'); - updateNavIndicator(); - - fetchNotifications(); - if (state.unreadCount > 0) { - fetchUnreadCount(); - } -} - -function closeNotifPanel() { - state.notifPanelOpen = false; - $('#notif-panel').classList.remove('open'); - $('#notif-backdrop').classList.remove('open'); - document.body.style.overflow = ''; - - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); - const targetNav = document.querySelector(`.nav-item[data-page="${state.currentPage}"]`); - if (targetNav) targetNav.classList.add('active'); - updateNavIndicator(); -} - -function showCapModal() { - return new Promise((resolve) => { - if (!customElements.get('cap-widget')) { - resolve(''); - return; - } - - const capApiEndpoint = 'https://cap.zero-host.org/f6c8171b08/'; - - const overlay = document.createElement('div'); - overlay.className = 'cap-modal-overlay'; - - const modal = document.createElement('div'); - modal.className = 'cap-modal'; - - const widget = document.createElement('cap-widget'); - widget.setAttribute('data-cap-api-endpoint', capApiEndpoint); - widget.setAttribute('theme', 'dark'); - - modal.appendChild(widget); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - setTimeout(() => { - const check = setInterval(() => { - const hiddenInput = widget.querySelector('[name="cap-token"]'); - const token = widget.token || (hiddenInput && hiddenInput.value) || ''; - if (token) { - clearInterval(check); - overlay.classList.add('cap-modal-fadeout'); - setTimeout(() => { - overlay.remove(); - resolve(token); - }, 300); - } - }, 200); - }, 100); - }); -} - -// ===== AUTH PAGES ===== -function renderLoginPage() { - const app = $('#app'); - app.innerHTML = html` -
-
- -

Welcome back

-

Sign in to your dashboard

- -
-
- - -
-
- - -
- - - - -
-
- `; - - $('#login-form').addEventListener('submit', handleLogin); - $('#go-register').addEventListener('click', (e) => { - e.preventDefault(); - navigateTo('signup'); - }); -} - -function renderRegisterPage() { - const app = $('#app'); - app.innerHTML = html` -
-
- -

Create account

-

Start hosting for free

-
-
-
- - -
-
- - -
-
- - -
- - - -
- -
-
- `; - - $('#register-form').addEventListener('submit', handleRegister); - $('#go-login').addEventListener('click', (e) => { - e.preventDefault(); - navigateTo('login'); - }); -} - -async function handleLogin(e) { - e.preventDefault(); - hideError(e.target); - const btn = $('#login-btn'); - btn.disabled = true; - btn.innerHTML = ' Signing in...'; - - try { - const capToken = await showCapModal(); - const data = await api('/auth/login', { - method: 'POST', - body: JSON.stringify({ - email: $('#login-email').value, - password: $('#login-password').value, - capToken, - }), - }); - state.token = data.token; - state.user = data.user; - localStorage.setItem('zh_token', data.token); - localStorage.setItem('zh_user', JSON.stringify(data.user)); - history.replaceState({ page: 'overview' }, '', '/'); - renderDashboard(); - } catch (err) { - showError(e.target, err.message); - } finally { - btn.disabled = false; - btn.innerHTML = 'Sign In'; - } -} - -async function handleRegister(e) { - e.preventDefault(); - hideError(e.target); - const btn = $('#register-btn'); - - const rgpdConsent = document.getElementById('reg-rgpd-consent')?.checked; - if (!rgpdConsent) { - showError(e.target, 'You must accept the privacy policy to create an account.'); - return; - } - - btn.disabled = true; - btn.innerHTML = ' Creating...'; - - try { - const capToken = await showCapModal(); - const data = await api('/auth/register', { - method: 'POST', - body: JSON.stringify({ - email: $('#reg-email').value, - username: $('#reg-username').value, - password: $('#reg-password').value, - capToken, - rgpdConsent: true, - }), - }); - state.token = data.token; - state.user = data.user; - localStorage.setItem('zh_token', data.token); - localStorage.setItem('zh_user', JSON.stringify(data.user)); - history.replaceState({ page: 'overview' }, '', '/'); - renderDashboard(); - } catch (err) { - showError(e.target, err.message); - } finally { - btn.disabled = false; - btn.innerHTML = 'Create Account'; - } -} - -let sidebarResizeInitialized = false; - -function initSidebarResize() { - const sidebar = $('#sidebar'); - const resizer = $('#sidebar-resizer'); - if (!sidebar || !resizer) return; - if (sidebarResizeInitialized) return; - sidebarResizeInitialized = true; - - if (localStorage.getItem('zh_sidebar_collapsed') === 'true') { - sidebar.classList.add('collapsed'); - document.querySelector('.main-content').style.marginLeft = ''; - } else { - const saved = localStorage.getItem('zh_sidebar_width'); - if (saved) { - const w = parseInt(saved, 10); - if (w >= 180 && w <= 600) { - sidebar.style.width = w + 'px'; - sidebar.style.setProperty('--sidebar-w', w + 'px'); - document.querySelector('.main-content').style.marginLeft = w + 'px'; - } - } - } - - let startX, startW; - - function onMouseDown(e) { - startX = e.clientX; - startW = sidebar.getBoundingClientRect().width; - sidebar.classList.add('resizing'); - document.documentElement.style.userSelect = 'none'; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - } - - function onMouseMove(e) { - const w = Math.min(600, Math.max(180, startW + e.clientX - startX)); - sidebar.style.width = w + 'px'; - sidebar.style.setProperty('--sidebar-w', w + 'px'); - document.querySelector('.main-content').style.marginLeft = w + 'px'; - } - - function onMouseUp() { - sidebar.classList.remove('resizing'); - document.documentElement.style.userSelect = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - const w = sidebar.getBoundingClientRect().width; - localStorage.setItem('zh_sidebar_width', Math.round(w)); - } - - resizer.addEventListener('mousedown', onMouseDown); -} - -function toggleSidebarCollapse() { - const sidebar = $('#sidebar'); - const main = document.querySelector('.main-content'); - const wasCollapsed = sidebar.classList.contains('collapsed'); - if (!wasCollapsed) { - sidebar.dataset.prevWidth = sidebar.style.width || sidebar.offsetWidth + 'px'; - sidebar.classList.add('collapsed'); - sidebar.style.width = ''; - main.style.marginLeft = ''; - } else { - sidebar.classList.remove('collapsed'); - let prev = sidebar.dataset.prevWidth || localStorage.getItem('zh_sidebar_width'); - if (!prev) prev = '260px'; - if (!prev.endsWith('px')) prev += 'px'; - sidebar.style.width = prev; - sidebar.style.setProperty('--sidebar-w', prev); - main.style.marginLeft = prev; - } - localStorage.setItem('zh_sidebar_collapsed', !wasCollapsed); - updateNavIndicator(); -} - -// ===== DASHBOARD ===== -async function renderDashboard() { - if (typeof adminTakingOver !== 'undefined' && adminTakingOver) return; - const app = $('#app'); - app.innerHTML = html` -
- - -
-
-

Notifications

- -
-
-
No notifications yet
-
-
-
- - - -
-
- - Account Restricted — Your account has been restricted. You cannot create or renew servers. -
-
-
-
-
-
-
-
-
-
- - - `; - - document.querySelectorAll('.nav-item[data-page]').forEach(item => { - item.addEventListener('click', (e) => { - e.preventDefault(); - const page = item.dataset.page; - navigateTo(page); - }); - }); - - $('#logout-btn').addEventListener('click', async () => { - try { await api('/auth/logout', { method: 'POST' }); } catch {} - state.token = null; - state.user = null; - localStorage.removeItem('zh_token'); - localStorage.removeItem('zh_user'); - navigateTo('login'); - }); - - $('#sidebar-user-info').addEventListener('click', (e) => { - if (e.target.closest('#logout-btn')) return; - navigateTo('account'); - }); - - $('#nav-notifications').addEventListener('click', (e) => { - e.preventDefault(); - toggleNotifPanel(); - }); - - $('#notif-backdrop').addEventListener('click', closeNotifPanel); - - $('#notif-mark-all').addEventListener('click', markAllAsRead); - - $('#sidebar-logo-link').addEventListener('click', (e) => { - e.preventDefault(); - toggleSidebarCollapse(); - }); - - $('#hamburger-toggle').addEventListener('click', () => { - $('#sidebar').classList.toggle('open'); - }); - - initSidebarResize(); - - initSidebarTooltip(); - - initIcons(); - - const page = window.location.pathname.replace('/', '') || 'overview'; - navigateTo(page); -} - -function initSidebarTooltip() { - const sidebar = $('#sidebar'); - const sidebarNav = document.querySelector('.sidebar-nav'); - const tooltip = document.getElementById('sidebar-tooltip'); - let tooltipTimer = null; - let tooltipQuickMode = false; - let currentItem = null; - - function showTooltipForItem(item) { - const text = item.textContent.trim(); - if (!text) return; - tooltip.textContent = text; - const rect = item.getBoundingClientRect(); - tooltip.style.top = (rect.top + rect.height / 2) + 'px'; - tooltip.style.left = (rect.right + 10) + 'px'; - tooltip.classList.add('visible'); - } - - function hideTooltip() { - tooltip.classList.remove('visible'); - clearTimeout(tooltipTimer); - tooltipQuickMode = false; - currentItem = null; - } - - sidebarNav.addEventListener('mouseover', (e) => { - const item = e.target.closest('.nav-item'); - if (!item) return; - if (!sidebar.classList.contains('collapsed')) return; - - if (item !== currentItem) { - clearTimeout(tooltipTimer); - currentItem = item; - - if (tooltipQuickMode) { - showTooltipForItem(item); - } else { - tooltipTimer = setTimeout(() => { - showTooltipForItem(item); - tooltipQuickMode = true; - }, 700); - } - } - }); - - sidebarNav.addEventListener('mouseleave', () => { - if (tooltip.classList.contains('visible') || tooltipTimer) { - hideTooltip(); - } - }); -} - -function navigateTo(page) { - if (state.notifPanelOpen) closeNotifPanel(); - const parts = page.split('/'); - let basePage = parts[0] || 'overview'; - const param = parts[1]; - const tab = parts[2]; - - // Auth pages - redirect to / if already logged in - if ((basePage === 'login' || basePage === 'signup') && state.token) { - basePage = 'overview'; - } - - // Handle auth pages (no dashboard needed) - if (basePage === 'login') { - renderLoginPage(); - history.pushState({ page: 'login' }, '', '/login'); - return; - } - if (basePage === 'signup') { - renderRegisterPage(); - history.pushState({ page: 'signup' }, '', '/signup'); - return; - } - - // Auth guard: require valid token for all other pages - if (!state.token) { - renderLoginPage(); - history.pushState({ page: 'login' }, '', '/login'); - return; - } - - // Ensure dashboard layout exists - if (!document.querySelector('.dashboard-layout')) { - renderDashboard(); - return; - } - - document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); - - state.currentPage = basePage; - state.serverId = param ? parseInt(param) : null; - state.serverDetailTab = tab || 'info'; - const url = basePage === 'overview' && !param ? '/' : `/${page}`; - history.pushState({ page: basePage, serverId: state.serverId }, '', url); - - if (basePage === 'server' && state.serverId) { - const targetPage = $('#page-server-detail'); - if (targetPage) targetPage.classList.add('active'); - renderServerDetail(state.serverId); - } else { - const targetPage = $(`#page-${basePage}`); - const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); - if (targetPage) targetPage.classList.add('active'); - if (targetNav) targetNav.classList.add('active'); - - if (basePage === 'overview') renderOverview(); - else if (basePage === 'servers') renderServers(); - else if (basePage === 'create') renderCreateServer(); - else if (basePage === 'pyrodactyl') renderPyrodactyl(); - else if (basePage === 'account') { - if (param === 'edit') renderAccountEdit(); - else if (param === 'dangerous') renderDangerous(); - else renderAccount(); - } else if (basePage === 'logs') { - renderLog(); - } - } - - updateNavIndicator(); - - if (window.innerWidth <= 768) { - $('#sidebar').classList.remove('open'); - } - - initIcons(); -} - -function updateNavIndicator() { - const activeNav = document.querySelector('.nav-item.active'); - const indicator = document.getElementById('nav-indicator'); - if (activeNav && indicator) { - indicator.style.top = activeNav.offsetTop + 'px'; - indicator.style.height = activeNav.offsetHeight + 'px'; - indicator.style.opacity = '1'; - } else if (indicator) { - indicator.style.opacity = '0'; - } -} - -window.addEventListener('popstate', () => { - const path = window.location.pathname; - if (path.startsWith('/admin')) return; // Handled by admin.js - const parts = path.replace(/^\//, '').split('/'); - let basePage = parts[0] || 'overview'; - const param = parts[1]; - const tab = parts[2]; - - // Auth pages - redirect to / if already logged in - if ((basePage === 'login' || basePage === 'signup') && state.token) { - basePage = 'overview'; - } - - if (basePage === 'login') { renderLoginPage(); return; } - if (basePage === 'signup') { renderRegisterPage(); return; } - if (!state.token) { renderLoginPage(); return; } - - document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); - - state.currentPage = basePage; - state.serverId = param ? parseInt(param) : null; - state.serverDetailTab = tab || 'info'; - - if (basePage === 'server' && state.serverId) { - const targetPage = $('#page-server-detail'); - if (targetPage) targetPage.classList.add('active'); - renderServerDetail(state.serverId); - } else { - const targetPage = $(`#page-${basePage}`); - const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); - if (targetPage) targetPage.classList.add('active'); - if (targetNav) targetNav.classList.add('active'); - - if (basePage === 'overview') renderOverview(); - else if (basePage === 'servers') renderServers(); - else if (basePage === 'create') renderCreateServer(); - else if (basePage === 'pyrodactyl') renderPyrodactyl(); - else if (basePage === 'account') { - if (param === 'edit') renderAccountEdit(); - else if (param === 'dangerous') renderDangerous(); - else renderAccount(); - } else if (basePage === 'logs') { - renderLog(); - } - } - updateNavIndicator(); -}); - -// ===== OVERVIEW ===== -async function renderOverview() { - const el = $('#page-overview'); - el.innerHTML = html` - -
-
โ€”
Total Servers
-
โ€”
Active Servers
-
โ€”
Server Slots
-
โ€”
To Renew
-
-
-
-

Your Servers

-
-
-
Loading...
-
-
- `; - - try { - const data = await api('/servers/overview'); - - if (data.restricted !== undefined) { - state.user = { ...state.user, restricted: data.restricted }; - const banner = $('#restricted-banner'); - if (banner) banner.style.display = data.restricted ? 'block' : 'none'; - } - - if (data.pteroError) { - $('#page-overview .card').insertAdjacentHTML('afterbegin', html` -
- ${data.pteroError} -
- `); - } - - $('#stat-total').textContent = data.totalServers; - $('#stat-active').textContent = data.activeServers; - const limit = data.serverLimit || 3; - $('#stat-slots').textContent = data.totalServers + '/' + limit; - const toRenew = data.servers.filter(s => { - const meta = s.serverMeta; - if (!meta) return false; - return new Date(meta.expires_at) <= new Date(); - }).length; - $('#stat-renew').textContent = toRenew; - state.servers = data.servers; - - if (data.servers.length === 0 && !data.pteroError) { - $('#recent-servers-list').innerHTML = html` -
-
-
No servers yet
-
Create your first server to get started
- -
- `; - $('#empty-create-server-btn').addEventListener('click', () => navigateTo('create')); - } else if (data.servers.length > 0) { - $('#recent-servers-list').innerHTML = html` -
- ${data.servers.slice(0, 6).map(s => renderServerCard(s)).join('')} -
- `; - } - - } catch (err) { - $('#recent-servers-list').innerHTML = html` -
Failed to load: ${err.message}
- `; - } - - initIcons(); -} - -let activityIcons = { - server_created: '', - server_renewed: '', - server_renamed: '', - server_reinstalled: '', - server_deleted: '', - account_registered: '', - password_changed: '', - email_changed: '', - account_deleted: '', - api_key_updated: '', - avatar_updated: '', - admin_suspend: '', - admin_unsuspend: '', - admin_renew_now: '', -}; - -function formatRelativeTime(dateStr) { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return mins + 'm ago'; - const hours = Math.floor(mins / 60); - if (hours < 24) return hours + 'h ago'; - const days = Math.floor(hours / 24); - if (days < 7) return days + 'd ago'; - return formatDate(dateStr); -} - -function getActionLabel(action) { - const labels = { - server_created: 'Created server', - server_renewed: 'Renewed server', - server_renamed: 'Renamed server', - server_reinstalled: 'Reinstalled server', - server_deleted: 'Deleted server', - account_registered: 'Account created', - password_changed: 'Password changed', - email_changed: 'Email changed', - account_deleted: 'Account deleted', - api_key_updated: 'API key updated', - avatar_updated: 'Profile picture updated', - admin_suspend: 'Server suspended (Admin)', - admin_unsuspend: 'Server unsuspended (Admin)', - admin_renew_now: 'Server force-renewed (Admin)', - }; - return labels[action] || action; -} - -async function renderLog(pageNum) { - const el = $('#page-logs'); - pageNum = pageNum || 1; - const limit = 50; - const offset = (pageNum - 1) * limit; - - el.innerHTML = html` - -
-
-
Loading...
-
-
- `; - - try { - const data = await api(`/activity?limit=${limit}&offset=${offset}`); - const list = $('#log-list'); - - if (data.activities.length === 0) { - list.innerHTML = '
No activity found.
'; - return; - } - - const pageInfo = data.totalPages > 1 ? html` -
- - Page ${data.page} of ${data.totalPages} (${data.total} total) - -
- ` : ''; - - list.innerHTML = html` - ${pageInfo} -
- ${data.activities.map(a => html` -
-
${activityIcons[a.action] || ''}
-
-
${getActionLabel(a.action)}
-
${a.details || ''}
-
-
${formatRelativeTime(a.created_at)}
-
- `).join('')} -
- ${pageInfo} - `; - initIcons(); - } catch (err) { - const list = $('#log-list'); - if (list) list.innerHTML = '
Could not load activity log.
'; - } -} - -function formatDate(d) { - if (!d) return 'N/A'; - const date = new Date(d); - return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); -} - -function daysRemaining(expiresAt) { - if (!expiresAt) return null; - const diff = new Date(expiresAt) - new Date(); - return Math.ceil(diff / (1000 * 60 * 60 * 24)); -} - -function renderServerCard(s) { - const eggName = s.eggDetails?.name || `Egg #${s.egg}`; - const alloc = s.allocationDetails; - const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; - const isSuspended = s.status === 'suspended'; - const meta = s.serverMeta; - const days = meta ? daysRemaining(meta.expires_at) : null; - const canRenew = days !== null && days <= 7 && days >= -7; - const isAdminSuspended = isSuspended && meta?.suspended_by === 'admin'; - const isExpiredRenewable = isSuspended && canRenew && !isAdminSuspended; - const statusClass = isAdminSuspended ? 'status-suspended' : (isExpiredRenewable ? 'status-expired' : (isInstalling ? 'status-installing' : 'status-active')); - const statusLabel = isAdminSuspended ? 'Suspended' : (isExpiredRenewable ? 'Expired' : (isInstalling ? 'Installing' : 'Active')); - const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); - const expClass = days !== null && days <= 0 ? 'expired' : (days !== null && days <= 7 ? 'expiring' : ''); - return html` -
-
- ${s.name} - ${statusLabel} -
-
- ${eggName} - ${allocStr} -
- - ${meta ? html` -
- Expires: ${formatDate(meta.expires_at)} - ${days !== null ? html`(${days > 0 ? days + ' days' : 'Expired'})` : ''} -
- ` : ''} -
- - ${canRenew && !isAdminSuspended ? html` - - ` : ''} -
-
- `; -} - -function renderServerRow(s) { - const eggName = s.eggDetails?.name || `Egg #${s.egg}`; - const alloc = s.allocationDetails; - const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; - const isSuspended = s.status === 'suspended'; - const meta = s.serverMeta; - const days = meta ? daysRemaining(meta.expires_at) : null; - const canRenew = days !== null && days <= 7 && days >= -7; - const isAdminSuspended = isSuspended && meta?.suspended_by === 'admin'; - const isExpiredRenewable = isSuspended && canRenew && !isAdminSuspended; - const powerState = s.currentState ? s.currentState.charAt(0).toUpperCase() + s.currentState.slice(1) : null; - const statusClass = isAdminSuspended ? 'status-suspended' : (isExpiredRenewable ? 'status-expired' : (isInstalling ? 'status-installing' : (powerState === 'Offline' ? 'status-offline' : 'status-active'))); - const statusLabel = isAdminSuspended ? 'Suspended' : (isExpiredRenewable ? 'Expired' : (isInstalling ? 'Installing' : (powerState || 'Active'))); - const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); - return html` - - ${s.name} - ${eggName} - ${allocStr} - - ${statusLabel} - - -
- Settings - - ${canRenew && !isAdminSuspended ? html` - - ` : ''} -
- - - `; -} - -// ===== SERVERS PAGE ===== -async function renderServers() { - const el = $('#page-servers'); - el.innerHTML = html` - -
-
- - -
-
- - - - -
-
-
- - - - - - - - - - - - - -
NameEggAllocationStatusActions
Loading...
-
- `; - - try { - const data = await api('/servers/list'); - state.servers = data.servers; - - if (data.servers.length === 0) { - const container = el.querySelector('.table-container'); - container.innerHTML = html` -
-
-
No servers yet
-
Create your first server to get started
- -
- `; - $('#servers-empty-create-btn').addEventListener('click', () => navigateTo('create')); - return; - } - - function applyFilters() { - const searchTerm = ($('#server-search-input')?.value || '').toLowerCase(); - const activeFilter = document.querySelector('.filter-btn.active')?.dataset?.filter || 'all'; - const searchWords = searchTerm.split(/\s+/).filter(Boolean); - - let filtered = data.servers; - - if (activeFilter !== 'all') { - filtered = filtered.filter(s => { - if (activeFilter === 'installing') return s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; - if (activeFilter === 'active') return s.status !== 'suspended' && !(s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false); - return s.status === activeFilter; - }); - } - - if (searchWords.length > 0) { - filtered = filtered.filter(s => { - const eggName = s.eggDetails?.name || `Egg #${s.egg}`; - const searchable = [s.name, eggName, s.identifier || '', s.node?.toString() || ''].join(' ').toLowerCase(); - return searchWords.every(w => searchable.includes(w)); - }); - } - - $('#servers-table-body').innerHTML = filtered.map(s => renderServerRow(s)).join(''); - - if (filtered.length === 0) { - $('#servers-table-body').innerHTML = html` - No servers match your search. - `; - } - } - - $('#server-search-input').addEventListener('input', applyFilters); - - document.querySelectorAll('#server-filters .filter-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('#server-filters .filter-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - applyFilters(); - }); - }); - - applyFilters(); - } catch (err) { - $('#servers-table-body').innerHTML = html` - Error: ${err.message} - `; - } - - initIcons(); -} - -// ===== CREATE SERVER ===== -let eggCache = []; - -async function renderCreateServer() { - const el = $('#page-create'); - el.innerHTML = html` - -
-
-
-
- - -
-
- -
- -
-
-
-
-
Default resources
-
- 512 MB RAM - 50% CPU - 3 GB Disk -
-
-
- -
- -
-
- `; - - $('#custom-egg-trigger').addEventListener('click', e => { - e.stopPropagation(); - $('#custom-egg-dropdown').classList.toggle('open'); - }); - document.addEventListener('click', function closeSelect(e) { - if (!e.target.closest('.custom-select')) { - $('#custom-egg-dropdown')?.classList.remove('open'); - } - }); - $('#create-server-form').addEventListener('submit', handleCreateServer); - - try { - const data = await api('/servers/eggs'); - eggCache = data.eggs; - const dropdown = $('#custom-egg-dropdown'); - const grouped = {}; - for (const e of data.eggs) { - if (!grouped[e.nestId]) grouped[e.nestId] = { name: e.nestName, eggs: [] }; - grouped[e.nestId].eggs.push(e); - } - let htmlStr = ''; - for (const nestId of Object.keys(grouped).sort()) { - htmlStr += `
${grouped[nestId].name || `Nest ${nestId}`}
`; - for (const e of grouped[nestId].eggs) { - htmlStr += `
${e.name}
`; - } - } - dropdown.innerHTML = htmlStr; - dropdown.querySelectorAll('.custom-select-option').forEach(opt => { - opt.addEventListener('click', () => { - $('#custom-egg-label').textContent = opt.textContent; - $('#custom-egg-trigger').dataset.value = opt.dataset.value; - dropdown.classList.remove('open'); - handleEggChange(); - }); - }); - } catch (err) { - $('#custom-egg-label').textContent = 'Failed to load eggs'; - $('#custom-egg-trigger').disabled = true; - showToast('Could not load eggs: ' + err.message, 'error'); - } - - initIcons(); -} - -function handleEggChange() {} - -async function handleCreateServer(e) { - e.preventDefault(); - const btn = $('#create-btn'); - btn.disabled = true; - btn.innerHTML = ' Creating...'; - - const name = $('#create-name').value.trim(); - const eggVal = $('#custom-egg-trigger').dataset.value; - if (!name || !eggVal) { - showToast('Please fill in all fields', 'error'); - btn.disabled = false; - btn.innerHTML = 'Create Server'; - return; - } - if (name.length < 1 || name.length > 255) { - showToast('Server name must be between 1 and 255 characters', 'error'); - btn.disabled = false; - btn.innerHTML = 'Create Server'; - return; - } - - const [eggId, nestId] = eggVal.split(',').map(Number); - const environment = {}; - - try { - const capToken = document.querySelector('[name="cap-token"]')?.value || ''; - await api('/servers/create', { - method: 'POST', - body: JSON.stringify({ name, nestId, eggId, environment, capToken }), - }); - showToast(`Server "${name}" created successfully!`, 'success'); - navigateTo('servers'); - } catch (err) { - showToast(err.message, 'error'); - } finally { - btn.disabled = false; - btn.innerHTML = 'Create Server'; - } -} - -// ===== PYRODACTYL PAGE ===== -function renderPyrodactyl() { - const el = $('#page-pyrodactyl'); - el.innerHTML = html` - -
-
-
- -
-

Opening Pyrodactyl...

-

- Click the button below to open the Pyrodactyl panel. -

- -
-
- `; - setTimeout(() => openPyrodactylPanel(), 500); - initIcons(); -} - -// ===== ACCOUNT PAGE ===== -function renderAccount() { - const el = $('#page-account'); - el.innerHTML = html` - - - `; - - $('#account-menu-edit').addEventListener('click', () => navigateTo('account/edit')); - $('#account-menu-logs').addEventListener('click', () => navigateTo('logs')); - $('#account-menu-dangerous').addEventListener('click', () => navigateTo('account/dangerous')); - $('#account-menu-logout').addEventListener('click', async () => { - initIcons(); - try { await api('/auth/logout', { method: 'POST' }); } catch {} - state.token = null; - state.user = null; - localStorage.removeItem('zh_token'); - localStorage.removeItem('zh_user'); - navigateTo('login'); - }); -} - -function renderAccountEdit() { - const el = $('#page-account'); - el.innerHTML = html` - - - `; - - $('#change-email-form').addEventListener('submit', handleChangeEmail); - $('#change-password-form').addEventListener('submit', handleChangePassword); - $('#api-key-form').addEventListener('submit', handleSaveApiKey); - - $('#avatar-choose-btn').addEventListener('click', () => { - $('#avatar-file-input').click(); - }); - - $('#avatar-file-input').addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - - const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; - if (!validTypes.includes(file.type)) { - $('#avatar-status').textContent = 'Invalid file type. Please use PNG, JPEG, GIF, or WebP.'; - $('#avatar-status').style.color = 'var(--accent-red)'; - return; - } - - if (file.size > 2 * 1024 * 1024) { - $('#avatar-status').textContent = 'File too large. Maximum size is 2MB.'; - $('#avatar-status').style.color = 'var(--accent-red)'; - return; - } - - const reader = new FileReader(); - reader.onload = (ev) => { - $('#avatar-preview-img').src = ev.target.result; - $('#avatar-preview-img').style.display = 'block'; - $('#avatar-preview-placeholder').style.display = 'none'; - $('#avatar-upload-btn').style.display = 'inline-flex'; - $('#avatar-upload-btn').disabled = false; - $('#avatar-status').textContent = 'Click "Upload" to save your new profile picture.'; - $('#avatar-status').style.color = 'var(--text-muted)'; - }; - reader.readAsDataURL(file); - }); - - $('#avatar-upload-btn').addEventListener('click', handleAvatarUpload); - - checkApiKeyStatus(); - initIcons(); -} - -async function handleAvatarUpload() { - const btn = $('#avatar-upload-btn'); - const status = $('#avatar-status'); - const img = $('#avatar-preview-img'); - - btn.disabled = true; - btn.innerHTML = ''; - status.textContent = ''; - - try { - const data = await api('/auth/upload-avatar', { - method: 'POST', - body: JSON.stringify({ image: img.src }), - }); - showToast('Profile picture updated successfully', 'success'); - status.textContent = 'Profile picture updated!'; - status.style.color = 'var(--accent-green)'; - btn.style.display = 'none'; - - const sidebarImg = document.querySelector('#avatar-container img'); - if (sidebarImg) { - sidebarImg.src = avatarUrl(state.user.id); - sidebarImg.dataset.fallbackTried = ''; - sidebarImg.style.display = ''; - const fallback = document.getElementById('avatar-fallback'); - if (fallback) fallback.style.display = 'none'; - } - } catch (err) { - status.textContent = err.message; - status.style.color = 'var(--accent-red)'; - } finally { - btn.disabled = false; - btn.innerHTML = 'Upload'; - } -} - -async function handleSaveApiKey(e) { - e.preventDefault(); - const btn = $('#save-api-key-btn'); - const input = $('#ptero-api-key-input'); - const status = $('#api-key-status'); - const key = input.value.trim(); - - if (!key) { - status.textContent = 'Please enter an API key.'; - status.style.color = 'var(--accent-red)'; - return; - } - - btn.disabled = true; - btn.innerHTML = ''; - status.textContent = ''; - - try { - await api('/servers/client-api-key', { - method: 'PUT', - body: JSON.stringify({ apiKey: key }), - }); - showToast('Pyrodactyl API key saved', 'success'); - renderApiKeySaved(); - return; - } catch (err) { - status.textContent = err.message; - status.style.color = 'var(--accent-red)'; - } finally { - btn.disabled = false; - btn.innerHTML = 'Save'; - } -} - -async function checkApiKeyStatus() { - try { - const data = await api('/servers/client-api-key'); - if (data.hasKey) { - renderApiKeySaved(); - } - } catch (err) { - // Keep default form if check fails - } -} - -function renderApiKeySaved() { - const section = $('#api-key-section-content'); - section.innerHTML = html` -
-

- Your Pyrodactyl API key is saved and active. -

-
- - -
-
- `; - $('#modify-api-key-btn').addEventListener('click', handleModifyApiKey); - $('#delete-api-key-btn').addEventListener('click', handleDeleteApiKey); -} - -function renderApiKeyForm() { - const section = $('#api-key-section-content'); - section.innerHTML = html` -
-
- - -
- -
-
- `; - $('#api-key-form').addEventListener('submit', handleSaveApiKey); -} - -function handleModifyApiKey() { - const overlay = $('#modal-overlay'); - const content = $('#modal-content'); - content.innerHTML = html` - -

- Enter your new Pyrodactyl API key. The old key will be replaced. -

-
- - -
- - `; - overlay.classList.add('open'); - $('#confirm-modify-api-key-btn').addEventListener('click', handleConfirmModifyApiKey); -} - -function handleDeleteApiKey() { - const overlay = $('#modal-overlay'); - const content = $('#modal-content'); - content.innerHTML = html` - -

- Are you sure you want to delete your Pyrodactyl API key? Live resource monitoring and power state detection will stop working. -

- - `; - overlay.classList.add('open'); - $('#confirm-delete-api-key-btn').addEventListener('click', handleConfirmDeleteApiKey); -} - -async function handleConfirmModifyApiKey() { - const btn = $('#confirm-modify-api-key-btn'); - const input = $('#modal-api-key-input'); - const key = input.value.trim(); - - if (!key) { - showToast('Please enter an API key', 'error'); - return; - } - - btn.disabled = true; - btn.innerHTML = ''; - - try { - await api('/servers/client-api-key', { - method: 'PUT', - body: JSON.stringify({ apiKey: key }), - }); - $('#modal-overlay').classList.remove('open'); - showToast('Pyrodactyl API key updated', 'success'); - renderApiKeySaved(); - } catch (err) { - showToast(err.message, 'error'); - } finally { - btn.disabled = false; - btn.innerHTML = 'Save'; - } -} - -async function handleConfirmDeleteApiKey() { - const btn = $('#confirm-delete-api-key-btn'); - btn.disabled = true; - btn.innerHTML = ''; - - try { - await api('/servers/client-api-key', { - method: 'DELETE', - }); - $('#modal-overlay').classList.remove('open'); - showToast('Pyrodactyl API key deleted', 'success'); - renderApiKeyForm(); - } catch (err) { - showToast(err.message, 'error'); - } finally { - btn.disabled = false; - btn.innerHTML = 'Delete'; - } -} - -async function handleChangeEmail(e) { - e.preventDefault(); - const btn = $('#change-email-btn'); - btn.disabled = true; - btn.innerHTML = ''; - try { - const newEmail = $('#acc-new-email').value.trim(); - const password = $('#acc-email-pw').value; - if (!newEmail || !password) { - showToast('Please fill in all fields', 'error'); - return; - } - const data = await api('/auth/change-email', { - method: 'POST', - body: JSON.stringify({ newEmail, password }), - }); - state.token = data.token; - state.user = data.user; - localStorage.setItem('zh_token', data.token); - localStorage.setItem('zh_user', JSON.stringify(data.user)); - $('#acc-new-email').placeholder = newEmail; - $('#acc-new-email').value = ''; - $('#acc-email-pw').value = ''; - showToast('Email updated successfully', 'success'); - } catch (err) { - showToast(err.message, 'error'); - } finally { - btn.disabled = false; - btn.innerHTML = 'Change Email'; - } -} - -async function handleChangePassword(e) { - e.preventDefault(); - const btn = $('#change-pw-btn'); - const currentPw = $('#acc-current-pw').value; - const newPw = $('#acc-new-pw').value; - const confirmPw = $('#acc-confirm-pw').value; - - if (!currentPw || !newPw || !confirmPw) { - showToast('Please fill in all fields', 'error'); - return; - } - - if (newPw.length < 8) { - showToast('New password must be at least 8 characters', 'error'); - return; - } - - if (newPw !== confirmPw) { - showToast('New passwords do not match', 'error'); - return; - } - - btn.disabled = true; - btn.innerHTML = ' Updating...'; - - try { - await api('/auth/change-password', { - method: 'POST', - body: JSON.stringify({ currentPassword: currentPw, newPassword: newPw }), - }); - showToast('Password changed successfully', 'success'); - $('#change-password-form').reset(); - } catch (err) { - showToast(err.message, 'error'); - } finally { - btn.disabled = false; - btn.innerHTML = 'Change Password'; - } -} - -function renderDangerous() { - const el = $('#page-account'); - el.innerHTML = html` - - - `; - $('#delete-account-btn').addEventListener('click', handleDeleteAccountClick); - $('#export-data-btn').addEventListener('click', handleExportData); - initIcons(); -} - -function handleDeleteAccountClick() { - const overlay = $('#modal-overlay'); - const content = $('#modal-content'); - content.innerHTML = html` - -

- This will permanently delete your account ${state.user?.username || ''} and all associated servers. This cannot be undone. -

-
- - -
- - `; - overlay.classList.add('open'); - - document.getElementById('confirm-delete-acc-btn').addEventListener('click', handleConfirmDeleteAccount); -} - -async function handleConfirmDeleteAccount() { - const password = $('#delete-acc-pw').value; - if (!password) { - showToast('Please enter your password', 'error'); - return; - } - - const btn = $('#confirm-delete-acc-btn'); - btn.disabled = true; - btn.innerHTML = ' Deleting...'; - - try { - await api('/auth/delete-account', { - method: 'POST', - body: JSON.stringify({ password }), - }); - $('#modal-overlay').classList.remove('open'); - showToast('Account deleted. Redirecting...', 'success'); - state.token = null; - state.user = null; - localStorage.removeItem('zh_token'); - localStorage.removeItem('zh_user'); - setTimeout(() => navigateTo('login'), 1500); - } catch (err) { - showToast(err.message, 'error'); - btn.disabled = false; - btn.innerHTML = 'Delete Forever'; - } -} - -// ===== SERVER DETAIL PAGE ===== -async function renderServerDetail(serverId) { - const el = $('#page-server-detail'); - - try { - const data = await api(`/servers/details/${serverId}`); - const s = data.server; - state.serverIdentifier = s.identifier; - const meta = s.serverMeta; - const eggName = s.eggDetails?.name || `Egg #${s.egg}`; - const alloc = s.allocationDetails; - const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); - const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; - const isSuspended = s.status === 'suspended'; - const days = meta ? daysRemaining(meta.expires_at) : null; - const canRenew = days !== null && days <= 7 && days >= -7; - const isAdminSuspended = isSuspended && meta?.suspended_by === 'admin'; - const isExpiredRenewable = isSuspended && canRenew && !isAdminSuspended; - const statusClass = isAdminSuspended ? 'status-suspended' : (isExpiredRenewable ? 'status-expired' : (isInstalling ? 'status-installing' : 'status-active')); - const statusLabel = isAdminSuspended ? 'Suspended' : (isExpiredRenewable ? 'Expired' : (isInstalling ? 'Installing' : 'Active')); - const expClass = days !== null && days <= 0 ? 'expired' : (days !== null && days <= 7 ? 'expiring' : ''); - - const activeTab = state.serverDetailTab || 'info'; - - el.innerHTML = html` - - - ${isAdminSuspended && meta?.suspend_reason ? html` -
-
Server Suspended
-
${meta.suspend_reason}
-
- ` : ''} - ${isExpiredRenewable ? html` -
-
Server Expired
-
This server has expired. Renew it to reactivate it instantly.
-
- ` : ''} - -
- - - -
-
- -
-
-
-

Server Info

-
-
Egg${eggName}
-
Allocation${allocStr}
-
IO${s.limits.io}
-
Swap${s.limits.swap > 0 ? s.limits.swap + ' MB' : 'Disabled'}
-
Identifier${s.identifier}
-
-
- -
-

Resources

-
-
-
${s.limits.memory > 0 ? s.limits.memory + ' MB' : 'โˆž'}
-
Memory
-
-
${s.limits.memory > 0 ? '512 MB max' : 'No limit'}
-
-
-
${s.limits.cpu}%
-
CPU
-
-
50% max
-
-
-
${s.limits.disk > 0 ? (s.limits.disk / 1024).toFixed(1) + ' GB' : 'โˆž'}
-
Disk
-
-
3 GB max
-
-
-
- -
-

Lifetime

- ${meta ? html` -
-
Created${formatDate(meta.created_at)}
-
Expires${formatDate(meta.expires_at)} ${days !== null ? '(' + (days > 0 ? days + ' days' : 'Expired') + ')' : ''}
-
Status${isExpiredRenewable ? 'expired' : meta.status}
- ${isAdminSuspended && meta.suspend_reason ? html` -
Reason${meta.suspend_reason}
- ` : ''} -
- ${canRenew && !isAdminSuspended ? html` - - ` : html` - ${days < -7 ? html`

This server has expired. Contact support to renew.

` : ''} - ${days > 7 ? html`

Renewal available within 7 days of expiration.

` : ''} - `} - ` : html` -

No lifetime data available for this server.

- `} -
-
-
- -
-
-
-

Live Resource Usage

- -
-
-
Fetching live data...
-
-
-
- -
- ${isAdminSuspended ? html` -
- -

Server Suspended

-

This server has been suspended by an administrator. No actions are available.

-

Please contact support via Discord for assistance.

-
- ` : isExpiredRenewable ? html` -
- -

Server Expired

-

This server has expired. Renew it to reactivate it instantly and get 90 more days.

- -
- ` : isSuspended ? html` -
- -

Server Expired

-

This server has been expired for too long. Please contact support to renew.

-

Reach out via Discord for assistance.

-
- ` : html` -
-
-
- -
-

Power Controls

-

- Current state: ${s.currentState || 'Unknown'} -

-
-
-
- - - -
-
- -
-
- -
-

Open Panel

-

Access the full Pyrodactyl control panel to manage files, console, databases, schedules, and more.

-
-
- -
- -
-
- -
-

Reinstall Server

-

Delete all files and reinstall the server from scratch. Only do this if you are experiencing critical issues with your server.

-
-
- -
- -
-
- -
-

Delete Server

-

Permanently delete this server and all associated data. This action is irreversible.

-
-
- -
-
- `} -
- `; - - if (activeTab === 'resources') { - fetchLiveResources(s.identifier); - } - - // Disable transition for initial position to prevent sliding from 0 - const indicator = $('#tab-indicator'); - const activeTabEl = $('#server-detail-tabs .tab.active'); - if (indicator && activeTabEl) { - const pos = activeTabEl.offsetLeft; - const w = activeTabEl.offsetWidth; - indicator.style.transition = 'none'; - indicator.style.left = pos + 'px'; - indicator.style.width = w + 'px'; - void indicator.offsetWidth; - indicator.style.transition = ''; - } - - initIcons(); - - } catch (err) { - el.innerHTML = html` -
-
-
Server not found
-
${err.message}
- -
- `; - initIcons(); - } -} - -async function fetchLiveResources(identifier) { - const container = $('#live-resources-container'); - if (!container) return; - - try { - const data = await api(`/servers/resources/${identifier}`); - - if (!data.resources) { - container.innerHTML = html` -
-
No live data
-
- ${data.error || 'Add your Pyrodactyl API key in Account โ†’ Linked Accounts to enable live monitoring.'} -
- -
- `; - return; - } - - const res = data.resources; - const currentState = data.current_state; - const cpuPct = Math.round(res.cpu_absolute || 0); - const memUsed = res.memory_bytes ? Math.round(res.memory_bytes / (1024 * 1024)) : 0; - const memLimit = res.memory_limit_bytes ? Math.round(res.memory_limit_bytes / (1024 * 1024)) : 512; - const memPct = memLimit > 0 ? Math.min(100, Math.round((memUsed / memLimit) * 100)) : 0; - const diskUsed = res.disk_bytes ? Math.round(res.disk_bytes / (1024 * 1024)) : 0; - const diskLimit = 3072; - const diskPct = diskLimit > 0 ? Math.min(100, Math.round((diskUsed / diskLimit) * 100)) : 0; - - function usageClass(pct) { - if (pct >= 80) return 'usage-high'; - if (pct >= 50) return 'usage-mid'; - return 'usage-low'; - } - - container.innerHTML = html` -
-
-
${cpuPct}%
-
CPU
-
-
${cpuPct >= 80 ? 'High load' : cpuPct >= 50 ? 'Moderate' : 'Idle'}
-
-
-
${memUsed} / ${memLimit} MB
-
Memory
-
-
${memPct}% used
-
-
-
${(diskUsed / 1024).toFixed(1)} / ${(diskLimit / 1024).toFixed(1)} GB
-
Disk
-
-
${diskPct}% used
-
-
-
- Updated ${formatRelativeTime(new Date().toISOString())} ยท - ${currentState ? html`Status: ${currentState}` : ''} -
- `; - - const refreshBtn = $('#refresh-resources-btn'); - if (refreshBtn && !refreshBtn.dataset.listenerAttached) { - refreshBtn.dataset.listenerAttached = '1'; - refreshBtn.addEventListener('click', () => { - container.innerHTML = '
Fetching live data...
'; - fetchLiveResources(identifier); - }); - } - - } catch (err) { - if (container) { - container.innerHTML = html` -
- Could not load live resources: ${err.message} -
- `; - } - } -} - -// ===== TAB SWITCHING ===== -function switchTab(tabBtn) { - if (!$('#tab-indicator')) return; - const tabName = tabBtn.dataset.tab; - state.serverDetailTab = tabName; - - document.querySelectorAll('#server-detail-tabs .tab').forEach(t => t.classList.remove('active')); - tabBtn.classList.add('active'); - - document.querySelectorAll('#page-server-detail .tab-content').forEach(c => c.style.display = 'none'); - const target = $('#server-tab-' + tabName); - if (target) target.style.display = 'block'; - - const indicator = $('#tab-indicator'); - if (indicator) { - indicator.style.left = tabBtn.offsetLeft + 'px'; - indicator.style.width = tabBtn.offsetWidth + 'px'; - } - - if (tabName === 'resources') { - const container = $('#live-resources-container'); - if (container && container.querySelector('.resource-gauge') === null) { - fetchLiveResources(state.serverIdentifier); - } - } - - const newUrl = `/server/${state.serverId}/${tabName}`; - history.pushState({ page: 'server', serverId: state.serverId, tab: tabName }, '', newUrl); -} - -// ===== DELETE SERVER ===== -document.addEventListener('click', function(e) { - const tabBtn = e.target.closest('.tab'); - if (tabBtn && !tabBtn.classList.contains('active')) { - e.preventDefault(); - switchTab(tabBtn); - return; - } - - const renewBtn = e.target.closest('.btn-renew-server'); - if (renewBtn) { - e.preventDefault(); - const serverId = parseInt(renewBtn.dataset.serverId); - renewBtn.disabled = true; - renewBtn.innerHTML = ''; - - api(`/servers/renew/${serverId}`, { method: 'POST' }) - .then(() => { - showToast('Server renewed for another 90 days!', 'success'); - if (state.currentPage === 'overview') renderOverview(); - else if (state.currentPage === 'servers') renderServers(); - }) - .catch(err => { - showToast(err.message, 'error'); - renewBtn.disabled = false; - renewBtn.innerHTML = 'Renew'; - }); - return; - } - - const renameBtn = e.target.closest('.btn-rename-server'); - if (renameBtn) { - e.preventDefault(); - const serverId = parseInt(renameBtn.dataset.serverId); - const serverName = renameBtn.dataset.serverName; - const overlay = $('#modal-overlay'); - const content = $('#modal-content'); - content.innerHTML = html` - -

- Enter a new name for ${serverName}. -

-
- - -
- - `; - overlay.classList.add('open'); - const input = $('#rename-server-input'); - input.focus(); - input.setSelectionRange(input.value.length, input.value.length); - return; - } - - const reinstallBtn = e.target.closest('.btn-reinstall-server'); - if (reinstallBtn) { - e.preventDefault(); - const serverId = parseInt(reinstallBtn.dataset.serverId); - const serverName = reinstallBtn.dataset.serverName; - const overlay = $('#modal-overlay'); - const content = $('#modal-content'); - content.innerHTML = html` - -

- Are you sure you want to reinstall ${serverName}? -

-
-

- This will delete all files, configurations, and data on the server and reinstall it from scratch. - Only do this if you are experiencing issues with the server. -

-
- - `; - overlay.classList.add('open'); - return; - } - - const delBtn = e.target.closest('.btn-delete-server'); - if (delBtn) { - e.preventDefault(); - const serverId = parseInt(delBtn.dataset.serverId); - const serverName = delBtn.dataset.serverName; - const overlay = $('#modal-overlay'); - const content = $('#modal-content'); - content.innerHTML = html` - -

- Are you sure you want to delete ${serverName}? - This action is irreversible and will permanently remove the server. -

- - `; - overlay.classList.add('open'); - return; - } - - if (e.target.closest('.modal-cancel-btn') || e.target.closest('.modal-overlay') && !e.target.closest('.modal')) { - $('#modal-overlay').classList.remove('open'); - return; - } - - const confirmBtn = e.target.closest('#confirm-delete-btn'); - if (confirmBtn) { - const serverId = parseInt(confirmBtn.dataset.serverId); - confirmBtn.disabled = true; - confirmBtn.innerHTML = ' Deleting...'; - - api(`/servers/${serverId}`, { method: 'DELETE' }) - .then(() => { - $('#modal-overlay').classList.remove('open'); - showToast('Server deleted successfully', 'success'); - if (state.currentPage === 'overview') renderOverview(); - else if (state.currentPage === 'servers') renderServers(); - else navigateTo('servers'); - }) - .catch(err => { - showToast(err.message, 'error'); - confirmBtn.disabled = false; - confirmBtn.innerHTML = 'Delete Forever'; - }); - return; - } - - const confirmRenameBtn = e.target.closest('#confirm-rename-btn'); - if (confirmRenameBtn) { - const serverId = parseInt(confirmRenameBtn.dataset.serverId); - const input = $('#rename-server-input'); - const name = input.value.trim(); - if (!name) { - showToast('Server name cannot be empty', 'error'); - input.focus(); - return; - } - confirmRenameBtn.disabled = true; - confirmRenameBtn.innerHTML = ' Renaming...'; - - api(`/servers/${serverId}`, { method: 'PATCH', body: JSON.stringify({ name }) }) - .then(() => { - $('#modal-overlay').classList.remove('open'); - showToast('Server renamed successfully', 'success'); - renderServerDetail(serverId); - }) - .catch(err => { - showToast(err.message, 'error'); - confirmRenameBtn.disabled = false; - confirmRenameBtn.innerHTML = 'Rename'; - }); - return; - } - - const confirmReinstallBtn = e.target.closest('#confirm-reinstall-btn'); - if (confirmReinstallBtn) { - const serverId = parseInt(confirmReinstallBtn.dataset.serverId); - confirmReinstallBtn.disabled = true; - confirmReinstallBtn.innerHTML = ' Reinstalling...'; - - api(`/servers/${serverId}/reinstall`, { method: 'POST' }) - .then(() => { - $('#modal-overlay').classList.remove('open'); - showToast('Server reinstall has been initiated', 'success'); - renderServerDetail(serverId); - }) - .catch(err => { - showToast(err.message, 'error'); - confirmReinstallBtn.disabled = false; - confirmReinstallBtn.innerHTML = 'Reinstall'; - }); - return; - } -}); - -// ===== RGPD: DATA EXPORT (Art. 15 & 20) ===== -async function handleExportData() { - const btn = $('#export-data-btn'); - if (!btn) return; - btn.disabled = true; - btn.innerHTML = ' Exporting...'; - - try { - const data = await api('/auth/export-data'); - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `zerohost-data-export-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - showToast('Data exported successfully', 'success'); - } catch (err) { - showToast('Failed to export data: ' + err.message, 'error'); - } finally { - btn.disabled = false; - btn.innerHTML = ` Export My Data`; - } -} - -// ===== INIT ===== -function init() { - const path = window.location.pathname; - - // Skip if admin route - handled by admin.js - if (path.startsWith('/admin')) return; - - const basePage = path.replace(/^\//, '').split('/')[0] || ''; - const token = localStorage.getItem('zh_token'); - - if (token) { - state.token = token; - try { - const user = JSON.parse(localStorage.getItem('zh_user')); - if (user) state.user = user; - } catch {} - } - - if (basePage === 'login' || basePage === 'signup') { - if (state.token) { - history.replaceState({ page: 'overview' }, '', '/'); - renderDashboard(); - } else if (basePage === 'login') { - renderLoginPage(); - } else { - renderRegisterPage(); - } - } else if (state.token) { - api('/servers/overview').then(() => { - renderDashboard(); - fetchUnreadCount(); - setInterval(fetchUnreadCount, 30000); - }).catch(() => { - renderDashboard(); - }); - } else { - renderLoginPage(); - history.replaceState({ page: 'login' }, '', '/login'); - } - - renderCookieBanner(); -} - -init(); - +function initIcons() { if (window.lucide) lucide.createIcons(); } + +const API_BASE = window.location.origin; + +const state = { + user: null, + token: localStorage.getItem('zh_token'), + currentPage: 'overview', + servers: [], + rgpdConsent: JSON.parse(localStorage.getItem('zh_rgpd_consent') || 'null'), + serverDetailTab: 'info', + notifications: [], + unreadCount: 0, + notifPanelOpen: false, +}; + +function setRgpdConsent(preferences) { + state.rgpdConsent = preferences; + localStorage.setItem('zh_rgpd_consent', JSON.stringify(preferences)); +} + +function renderCookieBanner() { + if (state.rgpdConsent) return; + const existing = document.getElementById('cookie-consent-banner'); + if (existing) return; + const banner = document.createElement('div'); + banner.id = 'cookie-consent-banner'; + banner.className = 'cookie-banner'; + banner.innerHTML = ` + + + `; + document.body.appendChild(banner); + + document.getElementById('cookie-essential-btn').addEventListener('click', () => { + setRgpdConsent({ essential: true, analytics: false, timestamp: new Date().toISOString() }); + banner.remove(); + }); + document.getElementById('cookie-accept-all-btn').addEventListener('click', () => { + setRgpdConsent({ essential: true, analytics: true, timestamp: new Date().toISOString() }); + banner.remove(); + }); +} + +const PTERO_URL = 'https://panel.zero-host.org'; + +function openPyrodactylPanel(serverIdentifier) { + const url = `${PTERO_URL}${serverIdentifier ? '/server/' + serverIdentifier : ''}`; + window.open(url, '_blank'); +} + +async function sendPowerCommand(identifier, signal, event) { + const btn = event?.target; + if (btn) { + btn.disabled = true; + btn.innerHTML = ''; + } + try { + await api(`/servers/power/${identifier}`, { method: 'POST', body: JSON.stringify({ signal }) }); + } catch (err) { + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +
+ +

Failed to send ${signal} command:

+

${err.message}

+
+ + `; + overlay.classList.add('open'); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = signal.charAt(0).toUpperCase() + signal.slice(1); + } + } +} + +function $(sel) { return document.querySelector(sel); } +function md5(s) { + function F(x,y,z) { return (x & y) | (~x & z); } + function G(x,y,z) { return (x & z) | (y & ~z); } + function H(x,y,z) { return x ^ y ^ z; } + function I(x,y,z) { return y ^ (x | ~z); } + function rol(x,n) { return (x << n) | (x >>> (32 - n)); } + function add(x,y) { return (x + y) >>> 0; } + function toHex(n) { let h=''; for(let i=0;i<4;i++) h+=((n>>(i*8))&0xFF).toString(16).padStart(2,'0'); return h; } + const T = new Array(64); + for(let i=1;i<=64;i++) T[i-1] = Math.floor(Math.abs(Math.sin(i)) * 0x100000000) >>> 0; + const s_ = [7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21]; + const S = (function(){ const r=[]; for(let i=0;i<64;i++) r[i]=s_[(i>>3<<2)+(i%4)]; return r; })(); + const K = [0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3,0,1,2,3]; + s = unescape(encodeURIComponent(s)); + const len = s.length; + const msg = []; for(let i=0;i>(8-(i%2)*8))&0xFF); + const bitLen = len * 8; + msg.push(0x80); + while((msg.length+8)%64!=0) msg.push(0); + for(let i=0;i<8;i++) msg.push((bitLen>>>(i*8))&0xFF); + let h0=0x67452301,h1=0xEFCDAB89,h2=0x98BADCFE,h3=0x10325476; + for(let i=0;i': '>', '"': '"', "'": ''' }; + return str.replace(/[&<>"']/g, m => map[m]); +} + +function html(strings, ...values) { + return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), ''); +} + +async function api(path, options = {}) { + const headers = { 'Content-Type': 'application/json', ...options.headers }; + if (state.token) { + headers['Authorization'] = `Bearer ${state.token}`; + } + let res; + try { + res = await fetch(`${API_BASE}/api${path}`, { ...options, headers }); + } catch { + throw new Error('Unable to reach the server. Please check your connection and try again.'); + } + const text = await res.text(); + let data; + try { + data = JSON.parse(text); + } catch { + throw new Error('Server error: please try again in a few moments.'); + } + if (res.status === 403 && data.error === 'Invalid or expired token') { + state.token = null; + state.user = null; + localStorage.removeItem('zh_token'); + localStorage.removeItem('zh_user'); + navigateTo('login'); + throw new Error('Session expired. Please sign in again.'); + } + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; +} + +function showToast(message, type = 'success') { + const container = $('#toast-container') || (() => { + const el = document.createElement('div'); + el.id = 'toast-container'; + el.className = 'toast-container'; + document.body.appendChild(el); + return el; + })(); + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.remove(), 4000); +} + +function showError(form, message) { + const errorEl = form.querySelector('.auth-error'); + if (errorEl) { + errorEl.textContent = message; + errorEl.classList.add('show'); + } +} + +function hideError(form) { + const errorEl = form.querySelector('.auth-error'); + if (errorEl) errorEl.classList.remove('show'); +} + +const NOTIF_ICONS = { + success: '', + error: '', + warning: '', + info: '', +}; + +function timeAgo(dateStr) { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return minutes + 'm ago'; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + 'h ago'; + const days = Math.floor(hours / 24); + if (days < 7) return days + 'd ago'; + return new Date(dateStr).toLocaleDateString(); +} + +async function fetchUnreadCount() { + try { + const data = await api('/notifications/unread-count'); + state.unreadCount = data.count; + updateNotifBadge(); + } catch {} +} + +async function fetchNotifications() { + try { + const data = await api('/notifications'); + state.notifications = data.notifications; + renderNotifications(); + } catch {} +} + +function showNotifDetailModal(notif) { + const existing = $('#notif-view-modal'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'notif-view-modal'; + overlay.id = 'notif-view-modal'; + overlay.innerHTML = html` +
+
+

${escapeHtml(notif.title)}

+ +
+
${escapeHtml(notif.message)}
+
+ `; + document.body.appendChild(overlay); + + requestAnimationFrame(() => { + overlay.classList.add('open'); + }); + + overlay.addEventListener('click', closeNotifDetailModal); + $('#notif-view-modal-close-btn')?.addEventListener('click', closeNotifDetailModal); + + initIcons(); +} + +function closeNotifDetailModal() { + const overlay = $('#notif-view-modal'); + if (!overlay) return; + overlay.classList.remove('open'); + setTimeout(() => overlay.remove(), 200); +} + +function renderNotifications() { + const list = $('#notif-panel-list'); + if (!list) return; + if (state.notifications.length === 0) { + list.innerHTML = '
No notifications yet
'; + return; + } + list.innerHTML = state.notifications.map(n => html` +
+
${NOTIF_ICONS[n.type] || NOTIF_ICONS.info}
+
+
${escapeHtml(n.title)}
+
${escapeHtml(n.message)}
+
${timeAgo(n.created_at)}
+
+ ${n.is_read ? '' : '
'} +
+ `).join(''); + + list.querySelectorAll('.notif-item').forEach(el => { + const msgEl = el.querySelector('.notif-item-msg'); + const isTruncated = msgEl && msgEl.scrollHeight > msgEl.clientHeight; + + el.addEventListener('click', () => { + const id = parseInt(el.dataset.id, 10); + if (el.classList.contains('notif-unread')) { + markAsRead(id); + } + if (isTruncated) { + const notif = state.notifications.find(n => n.id === id); + if (notif) showNotifDetailModal(notif); + } + }); + }); + initIcons(); +} + +async function markAsRead(id) { + try { + await api('/notifications/' + id + '/read', { method: 'PATCH' }); + const notif = state.notifications.find(n => n.id === id); + if (notif) notif.is_read = 1; + state.unreadCount = Math.max(0, state.unreadCount - 1); + updateNotifBadge(); + renderNotifications(); + } catch {} +} + +async function markAllAsRead() { + try { + await api('/notifications/read-all', { method: 'PATCH' }); + state.notifications.forEach(n => n.is_read = 1); + state.unreadCount = 0; + updateNotifBadge(); + renderNotifications(); + } catch {} +} + +function updateNotifBadge() { + const badge = $('#notif-badge'); + if (!badge) return; + if (state.unreadCount > 0) { + badge.textContent = state.unreadCount > 99 ? '99+' : state.unreadCount; + badge.style.display = 'flex'; + } else { + badge.style.display = 'none'; + } +} + +function toggleNotifPanel() { + if (state.notifPanelOpen) { + closeNotifPanel(); + } else { + openNotifPanel(); + } +} + +function openNotifPanel() { + state.notifPanelOpen = true; + $('#notif-panel').classList.add('open'); + $('#notif-backdrop').classList.add('open'); + document.body.style.overflow = 'hidden'; + + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + $('#nav-notifications').classList.add('active'); + updateNavIndicator(); + + fetchNotifications(); + if (state.unreadCount > 0) { + fetchUnreadCount(); + } +} + +function closeNotifPanel() { + state.notifPanelOpen = false; + $('#notif-panel').classList.remove('open'); + $('#notif-backdrop').classList.remove('open'); + document.body.style.overflow = ''; + + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + const targetNav = document.querySelector(`.nav-item[data-page="${state.currentPage}"]`); + if (targetNav) targetNav.classList.add('active'); + updateNavIndicator(); +} + +function showCapModal() { + return new Promise((resolve) => { + if (!customElements.get('cap-widget')) { + resolve(''); + return; + } + + const capApiEndpoint = 'https://cap.zero-host.org/f6c8171b08/'; + + const overlay = document.createElement('div'); + overlay.className = 'cap-modal-overlay'; + + const modal = document.createElement('div'); + modal.className = 'cap-modal'; + + const widget = document.createElement('cap-widget'); + widget.setAttribute('data-cap-api-endpoint', capApiEndpoint); + widget.setAttribute('theme', 'dark'); + + modal.appendChild(widget); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => { + const check = setInterval(() => { + const hiddenInput = widget.querySelector('[name="cap-token"]'); + const token = widget.token || (hiddenInput && hiddenInput.value) || ''; + if (token) { + clearInterval(check); + overlay.classList.add('cap-modal-fadeout'); + setTimeout(() => { + overlay.remove(); + resolve(token); + }, 300); + } + }, 200); + }, 100); + }); +} + +// ===== AUTH PAGES ===== +function renderLoginPage() { + const app = $('#app'); + app.innerHTML = html` +
+
+ +

Welcome back

+

Sign in to your dashboard

+
+
+
+ + +
+
+ + +
+ + +
+ +
+
+ `; + + $('#login-form').addEventListener('submit', handleLogin); + $('#go-register').addEventListener('click', (e) => { + e.preventDefault(); + navigateTo('signup'); + }); +} + +function renderRegisterPage() { + const app = $('#app'); + app.innerHTML = html` +
+
+ +

Create account

+

Start hosting for free

+
+
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ +
+
+ `; + + $('#register-form').addEventListener('submit', handleRegister); + $('#go-login').addEventListener('click', (e) => { + e.preventDefault(); + navigateTo('login'); + }); +} + +async function handleLogin(e) { + e.preventDefault(); + hideError(e.target); + const btn = $('#login-btn'); + btn.disabled = true; + btn.innerHTML = ' Signing in...'; + + try { + const capToken = await showCapModal(); + const data = await api('/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: $('#login-email').value, + password: $('#login-password').value, + capToken, + }), + }); + state.token = data.token; + state.user = data.user; + localStorage.setItem('zh_token', data.token); + localStorage.setItem('zh_user', JSON.stringify(data.user)); + history.replaceState({ page: 'overview' }, '', '/'); + renderDashboard(); + } catch (err) { + showError(e.target, err.message); + } finally { + btn.disabled = false; + btn.innerHTML = 'Sign In'; + } +} + +async function handleRegister(e) { + e.preventDefault(); + hideError(e.target); + const btn = $('#register-btn'); + + const rgpdConsent = document.getElementById('reg-rgpd-consent')?.checked; + if (!rgpdConsent) { + showError(e.target, 'You must accept the privacy policy to create an account.'); + return; + } + + btn.disabled = true; + btn.innerHTML = ' Creating...'; + + try { + const capToken = await showCapModal(); + const data = await api('/auth/register', { + method: 'POST', + body: JSON.stringify({ + email: $('#reg-email').value, + username: $('#reg-username').value, + password: $('#reg-password').value, + capToken, + rgpdConsent: true, + }), + }); + state.token = data.token; + state.user = data.user; + localStorage.setItem('zh_token', data.token); + localStorage.setItem('zh_user', JSON.stringify(data.user)); + history.replaceState({ page: 'overview' }, '', '/'); + renderDashboard(); + } catch (err) { + showError(e.target, err.message); + } finally { + btn.disabled = false; + btn.innerHTML = 'Create Account'; + } +} + +let sidebarResizeInitialized = false; + +function initSidebarResize() { + const sidebar = $('#sidebar'); + const resizer = $('#sidebar-resizer'); + if (!sidebar || !resizer) return; + if (sidebarResizeInitialized) return; + sidebarResizeInitialized = true; + + if (localStorage.getItem('zh_sidebar_collapsed') === 'true') { + sidebar.classList.add('collapsed'); + document.querySelector('.main-content').style.marginLeft = ''; + } else { + const saved = localStorage.getItem('zh_sidebar_width'); + if (saved) { + const w = parseInt(saved, 10); + if (w >= 180 && w <= 600) { + sidebar.style.width = w + 'px'; + document.documentElement.style.setProperty('--sidebar-w', w + 'px'); + document.querySelector('.main-content').style.marginLeft = w + 'px'; + } + } + } + + let startX, startW; + + function onMouseDown(e) { + startX = e.clientX; + startW = sidebar.getBoundingClientRect().width; + sidebar.classList.add('resizing'); + document.documentElement.style.userSelect = 'none'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + function onMouseMove(e) { + const w = Math.min(600, Math.max(180, startW + e.clientX - startX)); + sidebar.style.width = w + 'px'; + document.documentElement.style.setProperty('--sidebar-w', w + 'px'); + document.querySelector('.main-content').style.marginLeft = w + 'px'; + } + + function onMouseUp() { + sidebar.classList.remove('resizing'); + document.documentElement.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + const w = sidebar.getBoundingClientRect().width; + localStorage.setItem('zh_sidebar_width', Math.round(w)); + } + + resizer.addEventListener('mousedown', onMouseDown); +} + +function toggleSidebarCollapse() { + const sidebar = $('#sidebar'); + const main = document.querySelector('.main-content'); + const wasCollapsed = sidebar.classList.contains('collapsed'); + if (!wasCollapsed) { + sidebar.dataset.prevWidth = sidebar.style.width || sidebar.offsetWidth + 'px'; + sidebar.classList.add('collapsed'); + sidebar.style.width = ''; + main.style.marginLeft = ''; + } else { + sidebar.classList.remove('collapsed'); + let prev = sidebar.dataset.prevWidth || localStorage.getItem('zh_sidebar_width'); + if (!prev) prev = '260px'; + if (!prev.endsWith('px')) prev += 'px'; + sidebar.style.width = prev; + document.documentElement.style.setProperty('--sidebar-w', prev); + main.style.marginLeft = prev; + } + localStorage.setItem('zh_sidebar_collapsed', !wasCollapsed); + updateNavIndicator(); +} + +// ===== DASHBOARD ===== +async function renderDashboard() { + if (typeof adminTakingOver !== 'undefined' && adminTakingOver) return; + const app = $('#app'); + app.innerHTML = html` +
+ + +
+
+

Notifications

+ +
+
+
No notifications yet
+
+
+
+ + + +
+
+ + Account Restricted — Your account has been restricted. You cannot create or renew servers. +
+
+
+
+
+
+
+
+
+
+ + + `; + + document.querySelectorAll('.nav-item[data-page]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const page = item.dataset.page; + navigateTo(page); + }); + }); + + $('#logout-btn').addEventListener('click', async () => { + try { await api('/auth/logout', { method: 'POST' }); } catch {} + state.token = null; + state.user = null; + localStorage.removeItem('zh_token'); + localStorage.removeItem('zh_user'); + navigateTo('login'); + }); + + $('#sidebar-user-info').addEventListener('click', (e) => { + if (e.target.closest('#logout-btn')) return; + navigateTo('account'); + }); + + $('#nav-notifications').addEventListener('click', (e) => { + e.preventDefault(); + toggleNotifPanel(); + }); + + $('#notif-backdrop').addEventListener('click', closeNotifPanel); + + $('#notif-mark-all').addEventListener('click', markAllAsRead); + + $('#sidebar-logo-link').addEventListener('click', (e) => { + e.preventDefault(); + toggleSidebarCollapse(); + }); + + $('#hamburger-toggle').addEventListener('click', () => { + $('#sidebar').classList.toggle('open'); + }); + + initSidebarResize(); + + initSidebarTooltip(); + + initIcons(); + + const page = window.location.pathname.replace('/', '') || 'overview'; + navigateTo(page); +} + +function initSidebarTooltip() { + const sidebar = $('#sidebar'); + const sidebarNav = document.querySelector('.sidebar-nav'); + const tooltip = document.getElementById('sidebar-tooltip'); + let tooltipTimer = null; + let tooltipQuickMode = false; + let currentItem = null; + + function showTooltipForItem(item) { + const text = item.textContent.trim(); + if (!text) return; + tooltip.textContent = text; + const rect = item.getBoundingClientRect(); + tooltip.style.top = (rect.top + rect.height / 2) + 'px'; + tooltip.style.left = (rect.right + 10) + 'px'; + tooltip.classList.add('visible'); + } + + function hideTooltip() { + tooltip.classList.remove('visible'); + clearTimeout(tooltipTimer); + tooltipQuickMode = false; + currentItem = null; + } + + sidebarNav.addEventListener('mouseover', (e) => { + const item = e.target.closest('.nav-item'); + if (!item) return; + if (!sidebar.classList.contains('collapsed')) return; + + if (item !== currentItem) { + clearTimeout(tooltipTimer); + currentItem = item; + + if (tooltipQuickMode) { + showTooltipForItem(item); + } else { + tooltipTimer = setTimeout(() => { + showTooltipForItem(item); + tooltipQuickMode = true; + }, 700); + } + } + }); + + sidebarNav.addEventListener('mouseleave', () => { + if (tooltip.classList.contains('visible') || tooltipTimer) { + hideTooltip(); + } + }); +} + +function navigateTo(page) { + if (state.notifPanelOpen) closeNotifPanel(); + const parts = page.split('/'); + let basePage = parts[0] || 'overview'; + const param = parts[1]; + const tab = parts[2]; + + // Auth pages - redirect to / if already logged in + if ((basePage === 'login' || basePage === 'signup') && state.token) { + basePage = 'overview'; + } + + // Handle auth pages (no dashboard needed) + if (basePage === 'login') { + renderLoginPage(); + history.pushState({ page: 'login' }, '', '/login'); + return; + } + if (basePage === 'signup') { + renderRegisterPage(); + history.pushState({ page: 'signup' }, '', '/signup'); + return; + } + + // Auth guard: require valid token for all other pages + if (!state.token) { + renderLoginPage(); + history.pushState({ page: 'login' }, '', '/login'); + return; + } + + // Ensure dashboard layout exists + if (!document.querySelector('.dashboard-layout')) { + renderDashboard(); + return; + } + + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + + state.currentPage = basePage; + state.serverId = param ? parseInt(param) : null; + state.serverDetailTab = tab || 'info'; + const url = basePage === 'overview' && !param ? '/' : `/${page}`; + history.pushState({ page: basePage, serverId: state.serverId }, '', url); + + if (basePage === 'server' && state.serverId) { + const targetPage = $('#page-server-detail'); + if (targetPage) targetPage.classList.add('active'); + renderServerDetail(state.serverId); + } else { + const targetPage = $(`#page-${basePage}`); + const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); + if (targetPage) targetPage.classList.add('active'); + if (targetNav) targetNav.classList.add('active'); + + if (basePage === 'overview') renderOverview(); + else if (basePage === 'servers') renderServers(); + else if (basePage === 'create') { + const el = $('#page-create'); + el.innerHTML = html` + +
+ `; + createState.selectedNest = null; + createState.selectedEgg = null; + createState.selectedDockerImage = null; + createState.serverName = ''; + createState.step = 0; + (async () => { + try { + const data = await api('/servers/nests'); + createState.nests = data.nests; + renderWizardStep(0); + } catch (err) { + $('#create-wizard').innerHTML = html` +
+

Failed to load data: ${escapeHtml(err.message)}

+ +
+ `; + } + })(); + } + else if (basePage === 'pyrodactyl') renderPyrodactyl(); + else if (basePage === 'account') { + if (param === 'edit') renderAccountEdit(); + else if (param === 'dangerous') renderDangerous(); + else renderAccount(); + } else if (basePage === 'logs') { + renderLog(); + } + } + + updateNavIndicator(); + + if (window.innerWidth <= 768) { + $('#sidebar').classList.remove('open'); + } + + initIcons(); +} + +function updateNavIndicator() { + const activeNav = document.querySelector('.nav-item.active'); + const indicator = document.getElementById('nav-indicator'); + if (activeNav && indicator) { + indicator.style.top = activeNav.offsetTop + 'px'; + indicator.style.height = activeNav.offsetHeight + 'px'; + indicator.style.opacity = '1'; + } else if (indicator) { + indicator.style.opacity = '0'; + } +} + +window.addEventListener('popstate', () => { + const path = window.location.pathname; + if (path.startsWith('/admin')) return; // Handled by admin.js + const parts = path.replace(/^\//, '').split('/'); + let basePage = parts[0] || 'overview'; + const param = parts[1]; + const tab = parts[2]; + + // Auth pages - redirect to / if already logged in + if ((basePage === 'login' || basePage === 'signup') && state.token) { + basePage = 'overview'; + } + + if (basePage === 'login') { renderLoginPage(); return; } + if (basePage === 'signup') { renderRegisterPage(); return; } + if (!state.token) { renderLoginPage(); return; } + + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + + state.currentPage = basePage; + state.serverId = param ? parseInt(param) : null; + state.serverDetailTab = tab || 'info'; + + if (basePage === 'server' && state.serverId) { + const targetPage = $('#page-server-detail'); + if (targetPage) targetPage.classList.add('active'); + renderServerDetail(state.serverId); + } else { + const targetPage = $(`#page-${basePage}`); + const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); + if (targetPage) targetPage.classList.add('active'); + if (targetNav) targetNav.classList.add('active'); + + if (basePage === 'overview') renderOverview(); + else if (basePage === 'servers') renderServers(); + else if (basePage === 'create') { + const el = $('#page-create'); + el.innerHTML = html` + +
+ `; + createState.selectedNest = null; + createState.selectedEgg = null; + createState.selectedDockerImage = null; + createState.serverName = ''; + createState.step = 0; + (async () => { + try { + const data = await api('/servers/nests'); + createState.nests = data.nests; + renderWizardStep(0); + } catch (err) { + $('#create-wizard').innerHTML = html` +
+

Failed to load data: ${escapeHtml(err.message)}

+ +
+ `; + } + })(); + } + else if (basePage === 'pyrodactyl') renderPyrodactyl(); + else if (basePage === 'account') { + if (param === 'edit') renderAccountEdit(); + else if (param === 'dangerous') renderDangerous(); + else renderAccount(); + } else if (basePage === 'logs') { + renderLog(); + } + } + updateNavIndicator(); +}); + +// ===== OVERVIEW ===== +async function renderOverview() { + const el = $('#page-overview'); + el.innerHTML = html` + +
+
โ€”
Total Servers
+
โ€”
Active Servers
+
โ€”
Server Slots
+
โ€”
To Renew
+
+
+
+

Your Servers

+
+
+
Loading...
+
+
+ `; + + try { + const data = await api('/servers/overview'); + + if (data.restricted !== undefined) { + state.user = { ...state.user, restricted: data.restricted }; + const banner = $('#restricted-banner'); + if (banner) banner.style.display = data.restricted ? 'block' : 'none'; + } + + if (data.pteroError) { + $('#page-overview .card').insertAdjacentHTML('afterbegin', html` +
+ ${data.pteroError} +
+ `); + } + + $('#stat-total').textContent = data.totalServers; + $('#stat-active').textContent = data.activeServers; + const limit = data.serverLimit || 3; + $('#stat-slots').textContent = data.totalServers + '/' + limit; + const toRenew = data.servers.filter(s => { + const meta = s.serverMeta; + if (!meta) return false; + return new Date(meta.expires_at) <= new Date(); + }).length; + $('#stat-renew').textContent = toRenew; + state.servers = data.servers; + + if (data.servers.length === 0 && !data.pteroError) { + $('#recent-servers-list').innerHTML = html` +
+
+
No servers yet
+
Create your first server to get started
+ +
+ `; + $('#empty-create-server-btn').addEventListener('click', () => navigateTo('create')); + } else if (data.servers.length > 0) { + $('#recent-servers-list').innerHTML = html` +
+ ${data.servers.slice(0, 6).map(s => renderServerCard(s)).join('')} +
+ `; + } + + } catch (err) { + $('#recent-servers-list').innerHTML = html` +
Failed to load: ${err.message}
+ `; + } + + initIcons(); +} + +let activityIcons = { + server_created: '', + server_renewed: '', + server_renamed: '', + server_reinstalled: '', + server_deleted: '', + account_registered: '', + password_changed: '', + email_changed: '', + account_deleted: '', + api_key_updated: '', + avatar_updated: '', + admin_suspend: '', + admin_unsuspend: '', + admin_renew_now: '', +}; + +function formatRelativeTime(dateStr) { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return mins + 'm ago'; + const hours = Math.floor(mins / 60); + if (hours < 24) return hours + 'h ago'; + const days = Math.floor(hours / 24); + if (days < 7) return days + 'd ago'; + return formatDate(dateStr); +} + +function getActionLabel(action) { + const labels = { + server_created: 'Created server', + server_renewed: 'Renewed server', + server_renamed: 'Renamed server', + server_reinstalled: 'Reinstalled server', + server_deleted: 'Deleted server', + account_registered: 'Account created', + password_changed: 'Password changed', + email_changed: 'Email changed', + account_deleted: 'Account deleted', + api_key_updated: 'API key updated', + avatar_updated: 'Profile picture updated', + admin_suspend: 'Server suspended (Admin)', + admin_unsuspend: 'Server unsuspended (Admin)', + admin_renew_now: 'Server force-renewed (Admin)', + }; + return labels[action] || action; +} + +async function renderLog(pageNum) { + const el = $('#page-logs'); + pageNum = pageNum || 1; + const limit = 50; + const offset = (pageNum - 1) * limit; + + el.innerHTML = html` + +
+
+
Loading...
+
+
+ `; + + try { + const data = await api(`/activity?limit=${limit}&offset=${offset}`); + const list = $('#log-list'); + + if (data.activities.length === 0) { + list.innerHTML = '
No activity found.
'; + return; + } + + const pageInfo = data.totalPages > 1 ? html` +
+ + Page ${data.page} of ${data.totalPages} (${data.total} total) + +
+ ` : ''; + + list.innerHTML = html` + ${pageInfo} +
+ ${data.activities.map(a => html` +
+
${activityIcons[a.action] || ''}
+
+
${escapeHtml(getActionLabel(a.action))}
+
${escapeHtml(a.details || '')}
+
+
${formatRelativeTime(a.created_at)}
+
+ `).join('')} +
+ ${pageInfo} + `; + initIcons(); + } catch (err) { + const list = $('#log-list'); + if (list) list.innerHTML = '
Could not load activity log.
'; + } +} + +function formatDate(d) { + if (!d) return 'N/A'; + const date = new Date(d); + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +function daysRemaining(expiresAt) { + if (!expiresAt) return null; + const diff = new Date(expiresAt) - new Date(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +function renderServerCard(s) { + const eggName = s.eggDetails?.name || `Egg #${s.egg}`; + const alloc = s.allocationDetails; + const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; + const isSuspended = s.status === 'suspended'; + const meta = s.serverMeta; + const days = meta ? daysRemaining(meta.expires_at) : null; + const canRenew = days !== null && days <= 7 && days >= -7; + const isAdminSuspended = isSuspended && meta?.suspended_by === 'admin'; + const isExpiredRenewable = isSuspended && canRenew && !isAdminSuspended; + const statusClass = isAdminSuspended ? 'status-suspended' : (isExpiredRenewable ? 'status-expired' : (isInstalling ? 'status-installing' : 'status-active')); + const statusLabel = isAdminSuspended ? 'Suspended' : (isExpiredRenewable ? 'Expired' : (isInstalling ? 'Installing' : 'Active')); + const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); + const expClass = days !== null && days <= 0 ? 'expired' : (days !== null && days <= 7 ? 'expiring' : ''); + return html` +
+
+ ${escapeHtml(s.name)} + ${statusLabel} +
+
+ ${eggName} + ${allocStr} +
+ + ${meta ? html` +
+ Expires: ${formatDate(meta.expires_at)} + ${days !== null ? html`(${days > 0 ? days + ' days' : 'Expired'})` : ''} +
+ ` : ''} +
+ + ${canRenew && !isAdminSuspended ? html` + + ` : ''} +
+
+ `; +} + +function renderServerRow(s) { + const eggName = s.eggDetails?.name || `Egg #${s.egg}`; + const alloc = s.allocationDetails; + const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; + const isSuspended = s.status === 'suspended'; + const meta = s.serverMeta; + const days = meta ? daysRemaining(meta.expires_at) : null; + const canRenew = days !== null && days <= 7 && days >= -7; + const isAdminSuspended = isSuspended && meta?.suspended_by === 'admin'; + const isExpiredRenewable = isSuspended && canRenew && !isAdminSuspended; + const powerState = s.currentState ? s.currentState.charAt(0).toUpperCase() + s.currentState.slice(1) : null; + const statusClass = isAdminSuspended ? 'status-suspended' : (isExpiredRenewable ? 'status-expired' : (isInstalling ? 'status-installing' : (powerState === 'Offline' ? 'status-offline' : 'status-active'))); + const statusLabel = isAdminSuspended ? 'Suspended' : (isExpiredRenewable ? 'Expired' : (isInstalling ? 'Installing' : (powerState || 'Active'))); + const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); + return html` + + ${escapeHtml(s.name)} + ${eggName} + ${allocStr} + + ${statusLabel} + + +
+ Settings + + ${canRenew && !isAdminSuspended ? html` + + ` : ''} +
+ + + `; +} + +// ===== SERVERS PAGE ===== +async function renderServers() { + const el = $('#page-servers'); + el.innerHTML = html` + +
+
+ + +
+
+ + + + +
+
+
+ + + + + + + + + + + + + +
NameEggAllocationStatusActions
Loading...
+
+ `; + + try { + const data = await api('/servers/list'); + state.servers = data.servers; + + if (data.servers.length === 0) { + const container = el.querySelector('.table-container'); + container.innerHTML = html` +
+
+
No servers yet
+
Create your first server to get started
+ +
+ `; + $('#servers-empty-create-btn').addEventListener('click', () => navigateTo('create')); + initIcons(); + return; + } + + function applyFilters() { + const searchTerm = ($('#server-search-input')?.value || '').toLowerCase(); + const activeFilter = document.querySelector('.filter-btn.active')?.dataset?.filter || 'all'; + const searchWords = searchTerm.split(/\s+/).filter(Boolean); + + let filtered = data.servers; + + if (activeFilter !== 'all') { + filtered = filtered.filter(s => { + if (activeFilter === 'installing') return s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; + if (activeFilter === 'active') return s.status !== 'suspended' && !(s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false); + return s.status === activeFilter; + }); + } + + if (searchWords.length > 0) { + filtered = filtered.filter(s => { + const eggName = s.eggDetails?.name || `Egg #${s.egg}`; + const searchable = [s.name, eggName, s.identifier || '', s.node?.toString() || ''].join(' ').toLowerCase(); + return searchWords.every(w => searchable.includes(w)); + }); + } + + $('#servers-table-body').innerHTML = filtered.map(s => renderServerRow(s)).join(''); + + if (filtered.length === 0) { + $('#servers-table-body').innerHTML = html` + No servers match your search. + `; + } + } + + $('#server-search-input').addEventListener('input', applyFilters); + + document.querySelectorAll('#server-filters .filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#server-filters .filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + applyFilters(); + }); + }); + + applyFilters(); + } catch (err) { + $('#servers-table-body').innerHTML = html` + Error: ${err.message} + `; + } + + initIcons(); +} + +// ===== CREATE SERVER (Wizard) ===== +const createState = { + nests: [], + selectedNest: null, + selectedEgg: null, + selectedDockerImage: null, + serverName: '', + step: 0, +}; + +function renderWizardStep(step, direction) { + const prevStep = createState.step; + createState.step = step; + const container = $('#create-wizard'); + const headerEl = document.querySelector('#page-create .page-header'); + const steps = [ + { label: 'Nest', icon: 'layout-grid' }, + { label: 'Egg', icon: 'egg' }, + { label: 'Name', icon: 'type' }, + { label: 'Summary', icon: 'file-text' }, + ]; + + const totalSteps = steps.length; + const stepsHtml = steps.map((s, i) => html` +
+
+ ${s.label} +
+ `).join(''); + + let contentHtml = ''; + if (step === 0) contentHtml = renderNestStep(); + else if (step === 1) contentHtml = renderEggStep(); + else if (step === 2) contentHtml = renderNameStep(); + else if (step === 3) contentHtml = renderSummaryStep(); + + const slideClass = direction === 'next' ? 'slide-left' : direction === 'back' ? 'slide-right' : ''; + + const progressHtml = html` +
+
+ ${stepsHtml} +
+ `; + + if (headerEl) { + const existing = headerEl.querySelector('.wizard-progress'); + if (existing) existing.remove(); + headerEl.insertAdjacentHTML('beforeend', progressHtml); + } + + container.innerHTML = html` +
+
${contentHtml}
+
+ `; + + initIcons(); + attachWizardListeners(step); +} + +function renderNestStep() { + if (createState.nests.length === 0) { + return html`

No nests available.

`; + } + return html` +
Choose a Nest
+

Select the type of server you want to create

+
+ ${createState.nests.map(n => html` +
+ +
${escapeHtml(n.name)}
+ ${n.description ? html`
${escapeHtml(n.description)}
` : ''} +
+ `).join('')} +
+
+ +
+ `; +} + +function renderEggStep() { + const nest = createState.selectedNest; + if (!nest || !nest.eggs || nest.eggs.length === 0) { + return html`

No eggs available in this nest.

`; + } + + const eggCards = nest.eggs.map(e => { + const dockerImages = e.dockerImages || {}; + const images = Object.entries(dockerImages); + return html` +
+ +
+
${escapeHtml(e.name)}
+ ${e.description ? html`
${escapeHtml(e.description)}
` : ''} +
+
+ `; + }).join(''); + + let dockerSection = ''; + const selEgg = createState.selectedEgg; + if (selEgg) { + const images = Object.entries(selEgg.dockerImages || {}); + if (images.length > 1) { + const optionsHtml = images.map(([displayName, image]) => { + const shortName = displayName || image.split('/').pop().split(':').pop() || image; + return `
${escapeHtml(shortName)}
`; + }).join(''); + const currentLabel = createState.selectedDockerImage + ? (images.find(([, img]) => img === createState.selectedDockerImage)?.[0] || createState.selectedDockerImage.split('/').pop().split(':').pop()) + : 'Select an image'; + dockerSection = html` +
+
Docker Image
+

Choose a Docker image for this egg

+
+
+ ${escapeHtml(currentLabel)} + +
+
${optionsHtml}
+
+
+ `; + } + } + + return html` +
Choose an Egg
+

Select the software or game you want to run

+
${eggCards}
+ ${dockerSection} +
+ + +
+ `; +} + +function renderNameStep() { + return html` +
Name Your Server
+

Give your server a memorable name

+
+
+ + +
+
+ Allowed: letters, numbers, spaces, dots, dashes, underscores +
+
+
+ + +
+ `; +} + +function renderSummaryStep() { + const nest = createState.selectedNest; + const egg = createState.selectedEgg; + let dockerLabel = 'Default'; + if (egg && createState.selectedDockerImage) { + const images = Object.entries(egg.dockerImages || {}); + const found = images.find(([, img]) => img === createState.selectedDockerImage); + if (found) { + dockerLabel = found[0] || createState.selectedDockerImage.split('/').pop().split(':').pop() || createState.selectedDockerImage; + } else { + const raw = createState.selectedDockerImage; + dockerLabel = raw.split('/').pop().split(':').pop() || raw || 'Default'; + } + } + + return html` +
Summary
+

Review your choices before creating the server

+
+
+ Nest + ${escapeHtml(nest?.name || 'โ€”')} +
+
+ Egg + ${escapeHtml(egg?.name || 'โ€”')} +
+
+ Docker Image + ${escapeHtml(dockerLabel)} +
+
+ Server Name + ${escapeHtml(createState.serverName)} +
+
+
+
Default Resources
+
+ ${egg?.memory_limit != null ? egg.memory_limit + ' MB' : '512 MB'} RAM + ${egg?.cpu_limit != null ? egg.cpu_limit + '%' : '50%'} CPU + ${egg?.disk_limit != null ? (egg.disk_limit / 1024).toFixed(1) + ' GB' : '3 GB'} Disk +
+
+
+ +
+
+ + +
+ `; +} + +function attachWizardListeners(step) { + const backBtn = $('#wizard-back-btn'); + const nextBtn = $('#wizard-next-btn'); + const createBtn = $('#wizard-create-btn'); + + if (backBtn) { + backBtn.addEventListener('click', () => renderWizardStep(step - 1)); + } + + if (step === 0) { + const cards = document.querySelectorAll('.nest-card'); + cards.forEach(c => { + c.addEventListener('click', () => { + cards.forEach(c2 => c2.classList.remove('selected')); + c.classList.add('selected'); + const nestId = parseInt(c.dataset.nestId, 10); + createState.selectedNest = createState.nests.find(n => n.pteroNestId === nestId) || null; + createState.selectedEgg = null; + createState.selectedDockerImage = null; + const next = $('#wizard-next-btn'); + if (next) next.disabled = false; + }); + }); + if (nextBtn) { + nextBtn.addEventListener('click', () => renderWizardStep(1)); + } + } + + if (step === 1) { + const eggCards = document.querySelectorAll('.egg-card'); + eggCards.forEach(c => { + c.addEventListener('click', () => { + eggCards.forEach(c2 => c2.classList.remove('selected')); + c.classList.add('selected'); + const eggId = parseInt(c.dataset.eggId, 10); + const nest = createState.selectedNest; + createState.selectedEgg = nest?.eggs.find(e => e.eggId === eggId) || null; + createState.selectedDockerImage = null; + renderWizardStep(1); + }); + }); + + const dockerSelect = document.querySelector('#docker-select'); + if (dockerSelect) { + const trigger = dockerSelect.querySelector('.custom-select-trigger'); + const label = dockerSelect.querySelector('.custom-select-label'); + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.custom-select.open').forEach(s => s.classList.remove('open')); + dockerSelect.classList.add('open'); + }); + + dockerSelect.querySelectorAll('.custom-select-option').forEach(opt => { + opt.addEventListener('click', (e) => { + e.stopPropagation(); + createState.selectedDockerImage = opt.dataset.value; + label.textContent = opt.textContent; + dockerSelect.classList.remove('open'); + }); + }); + + document.addEventListener('click', () => { + dockerSelect.classList.remove('open'); + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', () => { + if (!createState.selectedEgg) return; + const images = Object.entries(createState.selectedEgg.dockerImages || {}); + if (images.length === 1) { + createState.selectedDockerImage = images[0][1]; + } else if (images.length > 1 && !createState.selectedDockerImage) { + showToast('Please select a Docker image', 'error'); + return; + } else if (images.length > 1 && createState.selectedDockerImage) { + // already selected + } + renderWizardStep(2); + }); + } + } + + if (step === 2) { + const input = $('#create-name-input'); + if (input) { + input.focus(); + input.addEventListener('input', () => { + const val = input.value.trim(); + createState.serverName = val; + const next = $('#wizard-next-btn'); + if (next) { + next.disabled = !isValidServerName(val); + } + }); + // Trigger initial validation + if (createState.serverName) { + input.value = createState.serverName; + const next = $('#wizard-next-btn'); + if (next) next.disabled = !isValidServerName(createState.serverName); + } + } + if (nextBtn) { + nextBtn.addEventListener('click', () => { + const name = createState.serverName; + if (!name) { showToast('Please enter a server name', 'error'); return; } + if (!isValidServerName(name)) { showToast('Server name contains invalid characters', 'error'); return; } + renderWizardStep(3); + }); + } + } + + if (step === 3 && createBtn) { + createBtn.addEventListener('click', handleWizardCreate); + } +} + +function isValidServerName(name) { + return name && name.length >= 1 && name.length <= 255 && /^[a-zA-Z0-9 _.-]+$/.test(name); +} + +async function handleWizardCreate() { + const btn = $('#wizard-create-btn'); + btn.disabled = true; + btn.innerHTML = ' Creating...'; + + const name = createState.serverName; + const nest = createState.selectedNest; + const egg = createState.selectedEgg; + if (!name || !nest || !egg) { + showToast('Missing required selections', 'error'); + btn.disabled = false; + btn.innerHTML = 'Create Server'; + return; + } + + const environment = {}; + const dockerImage = createState.selectedDockerImage || ''; + + try { + const capToken = document.querySelector('[name="cap-token"]')?.value || ''; + await api('/servers/create', { + method: 'POST', + body: JSON.stringify({ name, nestId: nest.pteroNestId, eggId: egg.eggId, environment, capToken, dockerImage }), + }); + showToast(`Server "${name}" created successfully!`, 'success'); + navigateTo('servers'); + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + btn.innerHTML = ' Create Server'; + initIcons(); + } +} + +// ===== PYRODACTYL PAGE ===== +function renderPyrodactyl() { + const el = $('#page-pyrodactyl'); + el.innerHTML = html` + +
+
+
+ +
+

Opening Pyrodactyl...

+

+ Click the button below to open the Pyrodactyl panel. +

+ +
+
+ `; + setTimeout(() => openPyrodactylPanel(), 500); + initIcons(); +} + +// ===== ACCOUNT PAGE ===== +function renderAccount() { + const el = $('#page-account'); + el.innerHTML = html` + + + `; + + $('#account-menu-edit').addEventListener('click', () => navigateTo('account/edit')); + $('#account-menu-logs').addEventListener('click', () => navigateTo('logs')); + $('#account-menu-dangerous').addEventListener('click', () => navigateTo('account/dangerous')); + $('#account-menu-logout').addEventListener('click', async () => { + initIcons(); + try { await api('/auth/logout', { method: 'POST' }); } catch {} + state.token = null; + state.user = null; + localStorage.removeItem('zh_token'); + localStorage.removeItem('zh_user'); + navigateTo('login'); + }); +} + +function renderAccountEdit() { + const el = $('#page-account'); + el.innerHTML = html` + + + `; + + $('#change-email-form').addEventListener('submit', handleChangeEmail); + $('#change-password-form').addEventListener('submit', handleChangePassword); + $('#api-key-form').addEventListener('submit', handleSaveApiKey); + + $('#avatar-choose-btn').addEventListener('click', () => { + $('#avatar-file-input').click(); + }); + + $('#avatar-file-input').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + $('#avatar-status').textContent = 'Invalid file type. Please use PNG, JPEG, GIF, or WebP.'; + $('#avatar-status').style.color = 'var(--accent-red)'; + return; + } + + if (file.size > 2 * 1024 * 1024) { + $('#avatar-status').textContent = 'File too large. Maximum size is 2MB.'; + $('#avatar-status').style.color = 'var(--accent-red)'; + return; + } + + const reader = new FileReader(); + reader.onload = (ev) => { + $('#avatar-preview-img').src = ev.target.result; + $('#avatar-preview-img').style.display = 'block'; + $('#avatar-preview-placeholder').style.display = 'none'; + $('#avatar-upload-btn').style.display = 'inline-flex'; + $('#avatar-upload-btn').disabled = false; + $('#avatar-status').textContent = 'Click "Upload" to save your new profile picture.'; + $('#avatar-status').style.color = 'var(--text-muted)'; + }; + reader.readAsDataURL(file); + }); + + $('#avatar-upload-btn').addEventListener('click', handleAvatarUpload); + + checkApiKeyStatus(); + initIcons(); +} + +async function handleAvatarUpload() { + const btn = $('#avatar-upload-btn'); + const status = $('#avatar-status'); + const img = $('#avatar-preview-img'); + + btn.disabled = true; + btn.innerHTML = ''; + status.textContent = ''; + + try { + const data = await api('/auth/upload-avatar', { + method: 'POST', + body: JSON.stringify({ image: img.src }), + }); + showToast('Profile picture updated successfully', 'success'); + status.textContent = 'Profile picture updated!'; + status.style.color = 'var(--accent-green)'; + btn.style.display = 'none'; + + const sidebarImg = document.querySelector('#avatar-container img'); + if (sidebarImg) { + sidebarImg.src = avatarUrl(state.user.id); + sidebarImg.dataset.fallbackTried = ''; + sidebarImg.style.display = ''; + const fallback = document.getElementById('avatar-fallback'); + if (fallback) fallback.style.display = 'none'; + } + } catch (err) { + status.textContent = err.message; + status.style.color = 'var(--accent-red)'; + } finally { + btn.disabled = false; + btn.innerHTML = 'Upload'; + } +} + +async function handleSaveApiKey(e) { + e.preventDefault(); + const btn = $('#save-api-key-btn'); + const input = $('#ptero-api-key-input'); + const status = $('#api-key-status'); + const key = input.value.trim(); + + if (!key) { + status.textContent = 'Please enter an API key.'; + status.style.color = 'var(--accent-red)'; + return; + } + + btn.disabled = true; + btn.innerHTML = ''; + status.textContent = ''; + + try { + await api('/servers/client-api-key', { + method: 'PUT', + body: JSON.stringify({ apiKey: key }), + }); + showToast('Pyrodactyl API key saved', 'success'); + renderApiKeySaved(); + return; + } catch (err) { + status.textContent = err.message; + status.style.color = 'var(--accent-red)'; + } finally { + btn.disabled = false; + btn.innerHTML = 'Save'; + } +} + +async function checkApiKeyStatus() { + try { + const data = await api('/servers/client-api-key'); + if (data.hasKey) { + renderApiKeySaved(); + } + } catch (err) { + // Keep default form if check fails + } +} + +function renderApiKeySaved() { + const section = $('#api-key-section-content'); + section.innerHTML = html` +
+

+ Your Pyrodactyl API key is saved and active. +

+
+ + +
+
+ `; + $('#modify-api-key-btn').addEventListener('click', handleModifyApiKey); + $('#delete-api-key-btn').addEventListener('click', handleDeleteApiKey); +} + +function renderApiKeyForm() { + const section = $('#api-key-section-content'); + section.innerHTML = html` +
+
+ + +
+ +
+
+ `; + $('#api-key-form').addEventListener('submit', handleSaveApiKey); +} + +function handleModifyApiKey() { + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Enter your new Pyrodactyl API key. The old key will be replaced. +

+
+ + +
+ + `; + overlay.classList.add('open'); + $('#confirm-modify-api-key-btn').addEventListener('click', handleConfirmModifyApiKey); +} + +function handleDeleteApiKey() { + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Are you sure you want to delete your Pyrodactyl API key? Live resource monitoring and power state detection will stop working. +

+ + `; + overlay.classList.add('open'); + $('#confirm-delete-api-key-btn').addEventListener('click', handleConfirmDeleteApiKey); +} + +async function handleConfirmModifyApiKey() { + const btn = $('#confirm-modify-api-key-btn'); + const input = $('#modal-api-key-input'); + const key = input.value.trim(); + + if (!key) { + showToast('Please enter an API key', 'error'); + return; + } + + btn.disabled = true; + btn.innerHTML = ''; + + try { + await api('/servers/client-api-key', { + method: 'PUT', + body: JSON.stringify({ apiKey: key }), + }); + $('#modal-overlay').classList.remove('open'); + showToast('Pyrodactyl API key updated', 'success'); + renderApiKeySaved(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Save'; + } +} + +async function handleConfirmDeleteApiKey() { + const btn = $('#confirm-delete-api-key-btn'); + btn.disabled = true; + btn.innerHTML = ''; + + try { + await api('/servers/client-api-key', { + method: 'DELETE', + }); + $('#modal-overlay').classList.remove('open'); + showToast('Pyrodactyl API key deleted', 'success'); + renderApiKeyForm(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Delete'; + } +} + +async function handleChangeEmail(e) { + e.preventDefault(); + const btn = $('#change-email-btn'); + btn.disabled = true; + btn.innerHTML = ''; + try { + const newEmail = $('#acc-new-email').value.trim(); + const password = $('#acc-email-pw').value; + if (!newEmail || !password) { + showToast('Please fill in all fields', 'error'); + return; + } + const data = await api('/auth/change-email', { + method: 'POST', + body: JSON.stringify({ newEmail, password }), + }); + state.token = data.token; + state.user = data.user; + localStorage.setItem('zh_token', data.token); + localStorage.setItem('zh_user', JSON.stringify(data.user)); + $('#acc-new-email').placeholder = newEmail; + $('#acc-new-email').value = ''; + $('#acc-email-pw').value = ''; + showToast('Email updated successfully', 'success'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Change Email'; + } +} + +async function handleChangePassword(e) { + e.preventDefault(); + const btn = $('#change-pw-btn'); + const currentPw = $('#acc-current-pw').value; + const newPw = $('#acc-new-pw').value; + const confirmPw = $('#acc-confirm-pw').value; + + if (!currentPw || !newPw || !confirmPw) { + showToast('Please fill in all fields', 'error'); + return; + } + + if (newPw.length < 8) { + showToast('New password must be at least 8 characters', 'error'); + return; + } + + if (newPw !== confirmPw) { + showToast('New passwords do not match', 'error'); + return; + } + + btn.disabled = true; + btn.innerHTML = ' Updating...'; + + try { + await api('/auth/change-password', { + method: 'POST', + body: JSON.stringify({ currentPassword: currentPw, newPassword: newPw }), + }); + showToast('Password changed successfully', 'success'); + $('#change-password-form').reset(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Change Password'; + } +} + +function renderDangerous() { + const el = $('#page-account'); + el.innerHTML = html` + + + `; + $('#delete-account-btn').addEventListener('click', handleDeleteAccountClick); + $('#export-data-btn').addEventListener('click', handleExportData); + initIcons(); +} + +function handleDeleteAccountClick() { + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ This will permanently delete your account ${state.user?.username || ''} and all associated servers. This cannot be undone. +

+
+ + +
+ + `; + overlay.classList.add('open'); + + document.getElementById('confirm-delete-acc-btn').addEventListener('click', handleConfirmDeleteAccount); +} + +async function handleConfirmDeleteAccount() { + const password = $('#delete-acc-pw').value; + if (!password) { + showToast('Please enter your password', 'error'); + return; + } + + const btn = $('#confirm-delete-acc-btn'); + btn.disabled = true; + btn.innerHTML = ' Deleting...'; + + try { + await api('/auth/delete-account', { + method: 'POST', + body: JSON.stringify({ password }), + }); + $('#modal-overlay').classList.remove('open'); + showToast('Account deleted. Redirecting...', 'success'); + state.token = null; + state.user = null; + localStorage.removeItem('zh_token'); + localStorage.removeItem('zh_user'); + setTimeout(() => navigateTo('login'), 1500); + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + btn.innerHTML = 'Delete Forever'; + } +} + +// ===== SERVER DETAIL PAGE ===== +async function renderServerDetail(serverId) { + const el = $('#page-server-detail'); + + try { + const data = await api(`/servers/details/${serverId}`); + const s = data.server; + state.serverIdentifier = s.identifier; + const meta = s.serverMeta; + const eggName = s.eggDetails?.name || `Egg #${s.egg}`; + const alloc = s.allocationDetails; + const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); + const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; + const isSuspended = s.status === 'suspended'; + const days = meta ? daysRemaining(meta.expires_at) : null; + const canRenew = days !== null && days <= 7 && days >= -7; + const isAdminSuspended = isSuspended && meta?.suspended_by === 'admin'; + const isExpiredRenewable = isSuspended && canRenew && !isAdminSuspended; + const statusClass = isAdminSuspended ? 'status-suspended' : (isExpiredRenewable ? 'status-expired' : (isInstalling ? 'status-installing' : 'status-active')); + const statusLabel = isAdminSuspended ? 'Suspended' : (isExpiredRenewable ? 'Expired' : (isInstalling ? 'Installing' : 'Active')); + const expClass = days !== null && days <= 0 ? 'expired' : (days !== null && days <= 7 ? 'expiring' : ''); + + const activeTab = state.serverDetailTab || 'info'; + + el.innerHTML = html` + + + ${isAdminSuspended && meta?.suspend_reason ? html` +
+
Server Suspended
+
${meta.suspend_reason}
+
+ ` : ''} + ${isExpiredRenewable ? html` +
+
Server Expired
+
This server has expired. Renew it to reactivate it instantly.
+
+ ` : ''} + +
+ + + +
+
+ +
+
+
+

Server Info

+
+
Egg${eggName}
+
Allocation${allocStr}
+
IO${s.limits.io}
+
Swap${s.limits.swap > 0 ? s.limits.swap + ' MB' : 'Disabled'}
+
Identifier${s.identifier}
+
+
+ +
+

Resources

+
+
+
${s.limits.memory > 0 ? s.limits.memory + ' MB' : 'โˆž'}
+
Memory
+
+
${s.limits.memory > 0 ? '512 MB max' : 'No limit'}
+
+
+
${s.limits.cpu}%
+
CPU
+
+
50% max
+
+
+
${s.limits.disk > 0 ? (s.limits.disk / 1024).toFixed(1) + ' GB' : 'โˆž'}
+
Disk
+
+
3 GB max
+
+
+
+ +
+

Lifetime

+ ${meta ? html` +
+
Created${formatDate(meta.created_at)}
+
Expires${formatDate(meta.expires_at)} ${days !== null ? '(' + (days > 0 ? days + ' days' : 'Expired') + ')' : ''}
+
Status${isExpiredRenewable ? 'expired' : meta.status}
+ ${isAdminSuspended && meta.suspend_reason ? html` +
Reason${meta.suspend_reason}
+ ` : ''} +
+ ${canRenew && !isAdminSuspended ? html` + + ` : html` + ${days < -7 ? html`

This server has expired. Contact support to renew.

` : ''} + ${days > 7 ? html`

Renewal available within 7 days of expiration.

` : ''} + `} + ` : html` +

No lifetime data available for this server.

+ `} +
+
+
+ +
+
+
+

Live Resource Usage

+ +
+
+
Fetching live data...
+
+
+
+ +
+ ${isAdminSuspended ? html` +
+ +

Server Suspended

+

This server has been suspended by an administrator. No actions are available.

+

Please contact support via Discord for assistance.

+
+ ` : isExpiredRenewable ? html` +
+ +

Server Expired

+

This server has expired. Renew it to reactivate it instantly and get 90 more days.

+ +
+ ` : isSuspended ? html` +
+ +

Server Expired

+

This server has been expired for too long. Please contact support to renew.

+

Reach out via Discord for assistance.

+
+ ` : html` +
+
+
+ +
+

Power Controls

+

+ Current state: ${s.currentState || 'Unknown'} +

+
+
+
+ + + +
+
+ +
+
+ +
+

Open Panel

+

Access the full Pyrodactyl control panel to manage files, console, databases, schedules, and more.

+
+
+ +
+ +
+
+ +
+

Reinstall Server

+

Delete all files and reinstall the server from scratch. Only do this if you are experiencing critical issues with your server.

+
+
+ +
+ +
+
+ +
+

Delete Server

+

Permanently delete this server and all associated data. This action is irreversible.

+
+
+ +
+
+ `} +
+ `; + + if (activeTab === 'resources') { + fetchLiveResources(s.identifier); + } + + // Disable transition for initial position to prevent sliding from 0 + const indicator = $('#tab-indicator'); + const activeTabEl = $('#server-detail-tabs .tab.active'); + if (indicator && activeTabEl) { + const pos = activeTabEl.offsetLeft; + const w = activeTabEl.offsetWidth; + indicator.style.transition = 'none'; + indicator.style.left = pos + 'px'; + indicator.style.width = w + 'px'; + void indicator.offsetWidth; + indicator.style.transition = ''; + } + + initIcons(); + + } catch (err) { + el.innerHTML = html` +
+
+
Server not found
+
${err.message}
+ +
+ `; + initIcons(); + } +} + +async function fetchLiveResources(identifier) { + const container = $('#live-resources-container'); + if (!container) return; + + try { + const data = await api(`/servers/resources/${identifier}`); + + if (!data.resources) { + container.innerHTML = html` +
+
No live data
+
+ ${data.error || 'Add your Pyrodactyl API key in Account โ†’ Linked Accounts to enable live monitoring.'} +
+ +
+ `; + return; + } + + const res = data.resources; + const currentState = data.current_state; + const cpuPct = Math.round(res.cpu_absolute || 0); + const memUsed = res.memory_bytes ? Math.round(res.memory_bytes / (1024 * 1024)) : 0; + const memLimit = res.memory_limit_bytes ? Math.round(res.memory_limit_bytes / (1024 * 1024)) : 512; + const memPct = memLimit > 0 ? Math.min(100, Math.round((memUsed / memLimit) * 100)) : 0; + const diskUsed = res.disk_bytes ? Math.round(res.disk_bytes / (1024 * 1024)) : 0; + const diskLimit = 3072; + const diskPct = diskLimit > 0 ? Math.min(100, Math.round((diskUsed / diskLimit) * 100)) : 0; + + function usageClass(pct) { + if (pct >= 80) return 'usage-high'; + if (pct >= 50) return 'usage-mid'; + return 'usage-low'; + } + + container.innerHTML = html` +
+
+
${cpuPct}%
+
CPU
+
+
${cpuPct >= 80 ? 'High load' : cpuPct >= 50 ? 'Moderate' : 'Idle'}
+
+
+
${memUsed} / ${memLimit} MB
+
Memory
+
+
${memPct}% used
+
+
+
${(diskUsed / 1024).toFixed(1)} / ${(diskLimit / 1024).toFixed(1)} GB
+
Disk
+
+
${diskPct}% used
+
+
+
+ Updated ${formatRelativeTime(new Date().toISOString())} ยท + ${currentState ? html`Status: ${currentState}` : ''} +
+ `; + + const refreshBtn = $('#refresh-resources-btn'); + if (refreshBtn && !refreshBtn.dataset.listenerAttached) { + refreshBtn.dataset.listenerAttached = '1'; + refreshBtn.addEventListener('click', () => { + container.innerHTML = '
Fetching live data...
'; + fetchLiveResources(identifier); + }); + } + + } catch (err) { + if (container) { + container.innerHTML = html` +
+ Could not load live resources: ${err.message} +
+ `; + } + } +} + +// ===== TAB SWITCHING ===== +function switchTab(tabBtn) { + if (!$('#tab-indicator')) return; + const tabName = tabBtn.dataset.tab; + state.serverDetailTab = tabName; + + document.querySelectorAll('#server-detail-tabs .tab').forEach(t => t.classList.remove('active')); + tabBtn.classList.add('active'); + + document.querySelectorAll('#page-server-detail .tab-content').forEach(c => c.style.display = 'none'); + const target = $('#server-tab-' + tabName); + if (target) target.style.display = 'block'; + + const indicator = $('#tab-indicator'); + if (indicator) { + indicator.style.left = tabBtn.offsetLeft + 'px'; + indicator.style.width = tabBtn.offsetWidth + 'px'; + } + + if (tabName === 'resources') { + const container = $('#live-resources-container'); + if (container && container.querySelector('.resource-gauge') === null) { + fetchLiveResources(state.serverIdentifier); + } + } + + const newUrl = `/server/${state.serverId}/${tabName}`; + history.pushState({ page: 'server', serverId: state.serverId, tab: tabName }, '', newUrl); +} + +// ===== DELETE SERVER ===== +document.addEventListener('click', function(e) { + const tabBtn = e.target.closest('.tab'); + if (tabBtn && !tabBtn.classList.contains('active')) { + e.preventDefault(); + switchTab(tabBtn); + return; + } + + const renewBtn = e.target.closest('.btn-renew-server'); + if (renewBtn) { + e.preventDefault(); + const serverId = parseInt(renewBtn.dataset.serverId); + renewBtn.disabled = true; + renewBtn.innerHTML = ''; + + api(`/servers/renew/${serverId}`, { method: 'POST' }) + .then(() => { + showToast('Server renewed for another 90 days!', 'success'); + if (state.currentPage === 'overview') renderOverview(); + else if (state.currentPage === 'servers') renderServers(); + }) + .catch(err => { + showToast(err.message, 'error'); + renewBtn.disabled = false; + renewBtn.innerHTML = 'Renew'; + }); + return; + } + + const renameBtn = e.target.closest('.btn-rename-server'); + if (renameBtn) { + e.preventDefault(); + const serverId = parseInt(renameBtn.dataset.serverId); + const serverName = renameBtn.dataset.serverName; + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Enter a new name for ${serverName}. +

+
+ + +
+ + `; + overlay.classList.add('open'); + const input = $('#rename-server-input'); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + return; + } + + const reinstallBtn = e.target.closest('.btn-reinstall-server'); + if (reinstallBtn) { + e.preventDefault(); + const serverId = parseInt(reinstallBtn.dataset.serverId); + const serverName = reinstallBtn.dataset.serverName; + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Are you sure you want to reinstall ${serverName}? +

+
+

+ This will delete all files, configurations, and data on the server and reinstall it from scratch. + Only do this if you are experiencing issues with the server. +

+
+ + `; + overlay.classList.add('open'); + return; + } + + const delBtn = e.target.closest('.btn-delete-server'); + if (delBtn) { + e.preventDefault(); + const serverId = parseInt(delBtn.dataset.serverId); + const serverName = delBtn.dataset.serverName; + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Are you sure you want to delete ${serverName}? + This action is irreversible and will permanently remove the server. +

+ + `; + overlay.classList.add('open'); + return; + } + + if (e.target.closest('.modal-cancel-btn') || e.target.closest('.modal-overlay') && !e.target.closest('.modal')) { + $('#modal-overlay').classList.remove('open'); + return; + } + + const confirmBtn = e.target.closest('#confirm-delete-btn'); + if (confirmBtn) { + const serverId = parseInt(confirmBtn.dataset.serverId); + confirmBtn.disabled = true; + confirmBtn.innerHTML = ' Deleting...'; + + api(`/servers/${serverId}`, { method: 'DELETE' }) + .then(() => { + $('#modal-overlay').classList.remove('open'); + showToast('Server deleted successfully', 'success'); + if (state.currentPage === 'overview') renderOverview(); + else if (state.currentPage === 'servers') renderServers(); + else navigateTo('servers'); + }) + .catch(err => { + showToast(err.message, 'error'); + confirmBtn.disabled = false; + confirmBtn.innerHTML = 'Delete Forever'; + }); + return; + } + + const confirmRenameBtn = e.target.closest('#confirm-rename-btn'); + if (confirmRenameBtn) { + const serverId = parseInt(confirmRenameBtn.dataset.serverId); + const input = $('#rename-server-input'); + const name = input.value.trim(); + if (!name) { + showToast('Server name cannot be empty', 'error'); + input.focus(); + return; + } + confirmRenameBtn.disabled = true; + confirmRenameBtn.innerHTML = ' Renaming...'; + + api(`/servers/${serverId}`, { method: 'PATCH', body: JSON.stringify({ name }) }) + .then(() => { + $('#modal-overlay').classList.remove('open'); + showToast('Server renamed successfully', 'success'); + renderServerDetail(serverId); + }) + .catch(err => { + showToast(err.message, 'error'); + confirmRenameBtn.disabled = false; + confirmRenameBtn.innerHTML = 'Rename'; + }); + return; + } + + const confirmReinstallBtn = e.target.closest('#confirm-reinstall-btn'); + if (confirmReinstallBtn) { + const serverId = parseInt(confirmReinstallBtn.dataset.serverId); + confirmReinstallBtn.disabled = true; + confirmReinstallBtn.innerHTML = ' Reinstalling...'; + + api(`/servers/${serverId}/reinstall`, { method: 'POST' }) + .then(() => { + $('#modal-overlay').classList.remove('open'); + showToast('Server reinstall has been initiated', 'success'); + renderServerDetail(serverId); + }) + .catch(err => { + showToast(err.message, 'error'); + confirmReinstallBtn.disabled = false; + confirmReinstallBtn.innerHTML = 'Reinstall'; + }); + return; + } +}); + +// ===== RGPD: DATA EXPORT (Art. 15 & 20) ===== +async function handleExportData() { + const btn = $('#export-data-btn'); + if (!btn) return; + btn.disabled = true; + btn.innerHTML = ' Exporting...'; + + try { + const data = await api('/auth/export-data'); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `zerohost-data-export-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast('Data exported successfully', 'success'); + } catch (err) { + showToast('Failed to export data: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = ` Export My Data`; + } +} + +// ===== INIT ===== +function init() { + const path = window.location.pathname; + + // Skip if admin route - handled by admin.js + if (path.startsWith('/admin')) return; + + const basePage = path.replace(/^\//, '').split('/')[0] || ''; + const token = localStorage.getItem('zh_token'); + + if (token) { + state.token = token; + try { + const user = JSON.parse(localStorage.getItem('zh_user')); + if (user) state.user = user; + } catch {} + } + + if (basePage === 'login' || basePage === 'signup') { + if (state.token) { + history.replaceState({ page: 'overview' }, '', '/'); + renderDashboard(); + } else if (basePage === 'login') { + renderLoginPage(); + } else { + renderRegisterPage(); + } + } else if (state.token) { + api('/servers/overview').then(() => { + renderDashboard(); + fetchUnreadCount(); + setInterval(fetchUnreadCount, 30000); + }).catch(() => { + renderDashboard(); + }); + } else { + renderLoginPage(); + history.replaceState({ page: 'login' }, '', '/login'); + } + + renderCookieBanner(); +} + +init(); + diff --git a/routes/admin.js b/routes/admin.js index 19725fd..186efee 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import argon2 from 'argon2'; import jwt from 'jsonwebtoken'; import { authenticateToken } from '../middleware/auth.js'; @@ -11,7 +12,15 @@ import { createNotification } from '../services/notification.js'; const router = Router(); const JWT_SECRET = process.env.JWT_SECRET; const PTERO_URL = process.env.PTERO_URL; -const PANEL_DB_NAME = process.env.PANEL_DB_NAME || 'panel'; +const PANEL_DB_NAME = (process.env.PANEL_DB_NAME || 'panel').replace(/[^a-zA-Z0-9_]/g, ''); + +const adminLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { error: 'Too many admin login attempts, try again later' }, + standardHeaders: true, + legacyHeaders: false, +}); function requireAdmin(req, res, next) { if (!req.user?.isAdmin) { @@ -20,13 +29,17 @@ function requireAdmin(req, res, next) { next(); } -router.post('/login', async (req, res) => { +router.post('/login', adminLoginLimiter, async (req, res) => { try { const { email, password, capToken } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } + if (typeof email !== 'string' || typeof password !== 'string') { + return res.status(400).json({ error: 'Invalid input types' }); + } + if (!await verifyCap(capToken)) { return res.status(400).json({ error: 'Please complete the security check' }); } @@ -125,7 +138,8 @@ router.post('/servers/:id/suspend', authenticateToken, requireAdmin, async (req, return res.status(400).json({ error: 'Invalid server ID' }); } - const reason = req.body.reason || 'Suspended by an Administrator. Please contact support.'; + const reason = (req.body.reason && typeof req.body.reason === 'string') + ? req.body.reason.slice(0, 500) : 'Suspended by an Administrator. Please contact support.'; await suspendPteroServer(serverId); await query('UPDATE server_meta SET status = ?, suspend_reason = ?, suspended_by = ? WHERE ptero_server_id = ?', ['suspended', reason, 'admin', serverId]); @@ -406,12 +420,21 @@ router.post('/users/:id/notify', authenticateToken, requireAdmin, async (req, re } const { title, message, type } = req.body; - if (!title || !title.trim()) { + if (typeof title !== 'string' || typeof message !== 'string') { + return res.status(400).json({ error: 'Invalid input types' }); + } + if (!title.trim()) { return res.status(400).json({ error: 'Title is required' }); } - if (!message || !message.trim()) { + if (title.trim().length > 255) { + return res.status(400).json({ error: 'Title must be 255 characters or less' }); + } + if (!message.trim()) { return res.status(400).json({ error: 'Message is required' }); } + if (message.trim().length > 5000) { + return res.status(400).json({ error: 'Message must be 5000 characters or less' }); + } const users = await query('SELECT id FROM users WHERE id = ?', [userId]); if (users.length === 0) { @@ -431,6 +454,46 @@ router.post('/users/:id/notify', authenticateToken, requireAdmin, async (req, re } }); +router.post('/notify-all', authenticateToken, requireAdmin, async (req, res) => { + try { + const { title, message, type } = req.body; + if (typeof title !== 'string' || typeof message !== 'string') { + return res.status(400).json({ error: 'Invalid input types' }); + } + if (!title.trim()) { + return res.status(400).json({ error: 'Title is required' }); + } + if (title.trim().length > 255) { + return res.status(400).json({ error: 'Title must be 255 characters or less' }); + } + if (!message.trim()) { + return res.status(400).json({ error: 'Message is required' }); + } + if (message.trim().length > 5000) { + return res.status(400).json({ error: 'Message must be 5000 characters or less' }); + } + + const validTypes = ['info', 'success', 'warning', 'error']; + const notifType = validTypes.includes(type) ? type : 'info'; + + const users = await query('SELECT id FROM users'); + if (users.length === 0) { + return res.status(404).json({ error: 'No users found' }); + } + + for (const user of users) { + await createNotification(user.id, title.trim(), message.trim(), notifType); + } + + await logActivity(req.user.userId, 'admin_notify_all', `Sent notification to all ${users.length} user(s): ${title.trim()}`, null); + + res.json({ success: true, count: users.length }); + } catch (err) { + console.error('Admin notify-all error:', err.message); + res.status(500).json({ error: 'Failed to send notifications: ' + err.message }); + } +}); + router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => { try { const userId = parseInt(req.params.id, 10); @@ -530,7 +593,7 @@ router.get('/settings/nests', authenticateToken, requireAdmin, async (req, res) const nests = await query('SELECT * FROM nests ORDER BY name ASC'); res.json({ nests }); } catch (err) { - res.status(500).json({ error: 'Failed to fetch nests' }); + res.status(500).json({ error: 'Failed to fetch nests: ' + err.message }); } }); @@ -550,6 +613,15 @@ router.post('/settings/nests', authenticateToken, requireAdmin, async (req, res) try { const { pteroNestId, name } = req.body; if (!pteroNestId) return res.status(400).json({ error: 'Nest ID is required' }); + if (typeof pteroNestId !== 'number' || isNaN(pteroNestId)) { + return res.status(400).json({ error: 'Nest ID must be a valid number' }); + } + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ error: 'Nest name is required' }); + } + if (name.trim().length > 255) { + return res.status(400).json({ error: 'Nest name must be 255 characters or less' }); + } const pteroNests = await getPteroNests(); const nest = pteroNests.find(n => n.id === pteroNestId); @@ -570,12 +642,20 @@ router.post('/settings/nests', authenticateToken, requireAdmin, async (req, res) router.put('/settings/nests/:id', authenticateToken, requireAdmin, async (req, res) => { try { const id = parseInt(req.params.id, 10); - const { name } = req.body; - if (!name) return res.status(400).json({ error: 'Name is required' }); - await query('UPDATE nests SET name = ? WHERE id = ?', [name, id]); + const { name, logo, description } = req.body; + if (name !== undefined && (typeof name !== 'string' || name.trim().length === 0)) return res.status(400).json({ error: 'Name cannot be empty' }); + if (name && name.trim().length > 255) return res.status(400).json({ error: 'Name must be 255 characters or less' }); + const updates = []; + const params = []; + if (name !== undefined) { updates.push('name = ?'); params.push(name.trim()); } + if (logo !== undefined) { updates.push('logo = ?'); params.push(logo || null); } + if (description !== undefined) { updates.push('description = ?'); params.push(description || null); } + if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); + params.push(id); + await query(`UPDATE nests SET ${updates.join(', ')} WHERE id = ?`, params); res.json({ success: true }); } catch (err) { - res.status(500).json({ error: 'Failed to rename nest' }); + res.status(500).json({ error: 'Failed to update nest: ' + err.message }); } }); @@ -615,10 +695,10 @@ router.get('/settings/nests/:nestId/eggs', authenticateToken, requireAdmin, asyn router.get('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, async (req, res) => { try { const { nestId, eggId } = req.params; - const [resources] = await query('SELECT * FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [nestId, eggId]); + const [resources] = await query('SELECT * FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [parseInt(nestId, 10), parseInt(eggId, 10)]); let eggDetails = null; try { - eggDetails = await getEgg(nestId, eggId); + eggDetails = await getEgg(parseInt(nestId, 10), parseInt(eggId, 10)); } catch {} res.json({ resources: resources || null, egg: eggDetails }); } catch (err) { @@ -628,20 +708,38 @@ router.get('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, asy router.put('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, async (req, res) => { try { - const { nestId, eggId } = req.params; - const { cpu_limit, memory_limit, disk_limit } = req.body; + const nestId = parseInt(req.params.nestId, 10); + const eggId = parseInt(req.params.eggId, 10); + if (isNaN(nestId) || isNaN(eggId)) return res.status(400).json({ error: 'Invalid nest or egg ID' }); + const { cpu_limit, memory_limit, disk_limit, logo } = req.body; + + const sanitized = { + cpu_limit: (cpu_limit != null && !isNaN(Number(cpu_limit))) ? Number(cpu_limit) : null, + memory_limit: (memory_limit != null && !isNaN(Number(memory_limit))) ? Number(memory_limit) : null, + disk_limit: (disk_limit != null && !isNaN(Number(disk_limit))) ? Number(disk_limit) : null, + logo: logo !== undefined ? (logo || null) : undefined, + }; + + if (sanitized.cpu_limit != null && sanitized.cpu_limit < 0) return res.status(400).json({ error: 'CPU limit cannot be negative' }); + if (sanitized.memory_limit != null && sanitized.memory_limit < 0) return res.status(400).json({ error: 'Memory limit cannot be negative' }); + if (sanitized.disk_limit != null && sanitized.disk_limit < 0) return res.status(400).json({ error: 'Disk limit cannot be negative' }); const [existing] = await query('SELECT id FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [nestId, eggId]); if (existing) { - await query('UPDATE egg_resources SET cpu_limit = ?, memory_limit = ?, disk_limit = ? WHERE id = ?', - [cpu_limit ?? null, memory_limit ?? null, disk_limit ?? null, existing.id]); + const updates = ['cpu_limit = ?', 'memory_limit = ?', 'disk_limit = ?']; + const vals = [sanitized.cpu_limit, sanitized.memory_limit, sanitized.disk_limit]; + if (sanitized.logo !== undefined) { updates.push('logo = ?'); vals.push(sanitized.logo); } + vals.push(existing.id); + await query(`UPDATE egg_resources SET ${updates.join(', ')} WHERE id = ?`, vals); } else { - await query('INSERT INTO egg_resources (ptero_nest_id, ptero_egg_id, cpu_limit, memory_limit, disk_limit) VALUES (?, ?, ?, ?, ?)', - [nestId, eggId, cpu_limit ?? null, memory_limit ?? null, disk_limit ?? null]); + const cols = ['ptero_nest_id', 'ptero_egg_id', 'cpu_limit', 'memory_limit', 'disk_limit']; + const vals = [nestId, eggId, sanitized.cpu_limit, sanitized.memory_limit, sanitized.disk_limit]; + if (sanitized.logo !== undefined) { cols.push('logo'); vals.push(sanitized.logo); } + await query(`INSERT INTO egg_resources (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, vals); } res.json({ success: true }); } catch (err) { - res.status(500).json({ error: 'Failed to save egg settings' }); + res.status(500).json({ error: 'Failed to save egg settings: ' + err.message }); } }); @@ -650,14 +748,24 @@ router.post('/settings/eggs/:nestId/:eggId/apply-all', authenticateToken, requir const { nestId, eggId } = req.params; const { cpu_limit, memory_limit, disk_limit } = req.body; + const sanitized = { + cpu_limit: (cpu_limit != null && !isNaN(Number(cpu_limit))) ? Number(cpu_limit) : null, + memory_limit: (memory_limit != null && !isNaN(Number(memory_limit))) ? Number(memory_limit) : null, + disk_limit: (disk_limit != null && !isNaN(Number(disk_limit))) ? Number(disk_limit) : null, + }; + + if (sanitized.cpu_limit != null && sanitized.cpu_limit < 0) return res.status(400).json({ error: 'CPU limit cannot be negative' }); + if (sanitized.memory_limit != null && sanitized.memory_limit < 0) return res.status(400).json({ error: 'Memory limit cannot be negative' }); + if (sanitized.disk_limit != null && sanitized.disk_limit < 0) return res.status(400).json({ error: 'Disk limit cannot be negative' }); + // Save to egg_resources first const [existing] = await query('SELECT id FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [nestId, eggId]); if (existing) { await query('UPDATE egg_resources SET cpu_limit = ?, memory_limit = ?, disk_limit = ? WHERE id = ?', - [cpu_limit ?? null, memory_limit ?? null, disk_limit ?? null, existing.id]); + [sanitized.cpu_limit, sanitized.memory_limit, sanitized.disk_limit, existing.id]); } else { await query('INSERT INTO egg_resources (ptero_nest_id, ptero_egg_id, cpu_limit, memory_limit, disk_limit) VALUES (?, ?, ?, ?, ?)', - [nestId, eggId, cpu_limit ?? null, memory_limit ?? null, disk_limit ?? null]); + [nestId, eggId, sanitized.cpu_limit, sanitized.memory_limit, sanitized.disk_limit]); } // Find all panel servers using this egg @@ -668,9 +776,9 @@ router.post('/settings/eggs/:nestId/:eggId/apply-all', authenticateToken, requir } const limits = {}; - if (cpu_limit != null) limits.cpu = cpu_limit; - if (memory_limit != null) limits.memory = memory_limit; - if (disk_limit != null) limits.disk = disk_limit; + if (sanitized.cpu_limit != null) limits.cpu = sanitized.cpu_limit; + if (sanitized.memory_limit != null) limits.memory = sanitized.memory_limit; + if (sanitized.disk_limit != null) limits.disk = sanitized.disk_limit; let updated = 0; for (const id of pteroIds) { diff --git a/routes/auth.js b/routes/auth.js index 432d28c..f5b66a8 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,9 +1,10 @@ import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import argon2 from 'argon2'; import jwt from 'jsonwebtoken'; import { readdir, writeFile, unlink, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; -import { join, dirname, extname } from 'path'; +import { join, dirname, extname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { query } from '../config/db.js'; import { generateToken, authenticateToken } from '../middleware/auth.js'; @@ -16,8 +17,55 @@ const UPLOAD_DIR = join(__dirname, '..', 'uploads', 'avatars'); const router = Router(); +const loginAttempts = new Map(); + +function getLoginDelay(ip) { + const now = Date.now(); + const entry = loginAttempts.get(ip); + if (!entry) return 0; + const sinceFirst = now - entry.firstAttempt; + if (sinceFirst > 15 * 60 * 1000) { + loginAttempts.delete(ip); + return 0; + } + if (entry.count <= 3) return 0; + if (entry.count <= 5) return 1000; + if (entry.count <= 8) return 3000; + if (entry.count <= 12) return 5000; + return 10000; +} + +function recordLoginAttempt(ip, success) { + if (success) { + loginAttempts.delete(ip); + return; + } + const now = Date.now(); + const entry = loginAttempts.get(ip); + if (entry) { + entry.count++; + } else { + loginAttempts.set(ip, { count: 1, firstAttempt: now }); + } +} + +setInterval(() => { + const cutoff = Date.now() - 15 * 60 * 1000; + for (const [ip, entry] of loginAttempts) { + if (entry.firstAttempt < cutoff) loginAttempts.delete(ip); + } +}, 60 * 1000); + const MIME_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }; +const sensitiveLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + message: { error: 'Too many sensitive operations, try again later' }, + standardHeaders: true, + legacyHeaders: false, +}); + function getClientIp(req) { const forwarded = req.headers['x-forwarded-for']; if (forwarded) return forwarded.split(',')[0].trim(); @@ -48,11 +96,17 @@ async function isVpnOrProxy(ip) { } } +const MAX_EMAIL_LENGTH = 254; +const MAX_USERNAME_LENGTH = 32; +const MAX_PASSWORD_LENGTH = 128; + function validateEmail(email) { + if (typeof email !== 'string' || email.length > MAX_EMAIL_LENGTH) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function validateUsername(username) { + if (typeof username !== 'string' || username.length > MAX_USERNAME_LENGTH) return false; return /^[a-zA-Z0-9_-]{3,32}$/.test(username); } @@ -61,10 +115,20 @@ router.post('/register', async (req, res) => { try { const { email, username, password, capToken, rgpdConsent } = req.body; + const ip = getClientIp(req); + const delay = getLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + if (!email || !username || !password) { return res.status(400).json({ error: 'Email, username and password are required' }); } + if (typeof email !== 'string' || typeof username !== 'string' || typeof password !== 'string') { + return res.status(400).json({ error: 'Invalid input types' }); + } + if (!rgpdConsent) { return res.status(400).json({ error: 'You must accept the privacy policy to create an account.' }); } @@ -77,27 +141,28 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Username must be 3-32 chars (letters, numbers, underscore, hyphen)' }); } - if (password.length < 8) { - return res.status(400).json({ error: 'Password must be at least 8 characters' }); + if (password.length < 8 || password.length > MAX_PASSWORD_LENGTH) { + return res.status(400).json({ error: 'Password must be between 8 and 128 characters' }); } if (!await verifyCap(capToken)) { + recordLoginAttempt(ip, false); return res.status(400).json({ error: 'Please complete the security check' }); } - const ip = getClientIp(req); - if (await isVpnOrProxy(ip)) { return res.status(403).json({ error: 'VPNs and proxies are not allowed. Please disable them to register.' }); } const ipCount = await query('SELECT COUNT(DISTINCT user_id) AS cnt FROM user_ips WHERE ip_address = ?', [ip]); if (ipCount[0].cnt >= 2) { + recordLoginAttempt(ip, false); return res.status(403).json({ error: 'Too many accounts registered from this IP address.' }); } const existing = await query('SELECT id FROM users WHERE email = ? OR username = ?', [email, username]); if (existing.length > 0) { + recordLoginAttempt(ip, false); return res.status(409).json({ error: 'Email or username already exists' }); } @@ -143,6 +208,8 @@ router.post('/register', async (req, res) => { await logActivity(localUserId, 'account_registered', 'Created account'); + recordLoginAttempt(ip, true); + res.status(201).json({ token, user: { @@ -177,19 +244,27 @@ router.post('/login', async (req, res) => { return res.status(400).json({ error: 'Email and password are required' }); } + const ip = getClientIp(req); + const delay = getLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + // Cap verification if (!await verifyCap(capToken)) { + recordLoginAttempt(ip, false); return res.status(400).json({ error: 'Please complete the security check' }); } // VPN / Proxy detection - const ip = getClientIp(req); + if (await isVpnOrProxy(ip)) { return res.status(403).json({ error: 'VPNs and proxies are not allowed. Please disable them to sign in.' }); } const users = await query('SELECT * FROM users WHERE email = ?', [email]); if (users.length === 0) { + recordLoginAttempt(ip, false); if (process.env.NODE_ENV !== 'production') console.log('[LOGIN] User not found for email:', email); return res.status(401).json({ error: 'Invalid email or password' }); } @@ -204,6 +279,7 @@ router.post('/login', async (req, res) => { const validPassword = await argon2.verify(user.password_hash, password, { type: argon2.argon2id }); if (!validPassword) { + recordLoginAttempt(ip, false); if (process.env.NODE_ENV !== 'production') console.log('[LOGIN] Password mismatch for user:', user.id); return res.status(401).json({ error: 'Invalid email or password' }); } @@ -224,6 +300,8 @@ router.post('/login', async (req, res) => { maxAge: 2 * 60 * 60 * 1000, }); + recordLoginAttempt(ip, true); + res.json({ token, user: { @@ -243,7 +321,7 @@ router.post('/login', async (req, res) => { } }); -router.post('/change-password', authenticateToken, async (req, res) => { +router.post('/change-password', authenticateToken, sensitiveLimiter, async (req, res) => { try { const { currentPassword, newPassword } = req.body; const pteroId = req.user?.pteroId; @@ -252,8 +330,12 @@ router.post('/change-password', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'Current password and new password are required' }); } - if (newPassword.length < 8) { - return res.status(400).json({ error: 'New password must be at least 8 characters' }); + if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { + return res.status(400).json({ error: 'Invalid input types' }); + } + + if (newPassword.length < 8 || newPassword.length > MAX_PASSWORD_LENGTH) { + return res.status(400).json({ error: 'New password must be between 8 and 128 characters' }); } const users = await query('SELECT * FROM users WHERE ptero_user_id = ?', [pteroId]); @@ -275,7 +357,7 @@ router.post('/change-password', authenticateToken, async (req, res) => { parallelism: 4, }); - await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, user.id]); + await query('UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?', [passwordHash, user.id]); try { await updatePteroPassword(pteroId, newPassword); @@ -291,7 +373,7 @@ router.post('/change-password', authenticateToken, async (req, res) => { } }); -router.post('/change-email', authenticateToken, async (req, res) => { +router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, res) => { try { const { newEmail, password } = req.body; const pteroId = req.user?.pteroId; @@ -301,6 +383,10 @@ router.post('/change-email', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'New email and password are required' }); } + if (typeof newEmail !== 'string' || typeof password !== 'string') { + return res.status(400).json({ error: 'Invalid input types' }); + } + if (!validateEmail(newEmail)) { return res.status(400).json({ error: 'Invalid email format' }); } @@ -364,7 +450,7 @@ router.post('/change-email', authenticateToken, async (req, res) => { } }); -router.post('/delete-account', authenticateToken, async (req, res) => { +router.post('/delete-account', authenticateToken, sensitiveLimiter, async (req, res) => { try { const { password } = req.body; const userId = req.user?.userId; @@ -434,7 +520,7 @@ router.post('/delete-account', authenticateToken, async (req, res) => { } }); -router.post('/upload-avatar', authenticateToken, async (req, res) => { +router.post('/upload-avatar', authenticateToken, sensitiveLimiter, async (req, res) => { try { const { image } = req.body; if (!image) { @@ -447,6 +533,11 @@ router.post('/upload-avatar', authenticateToken, async (req, res) => { } const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]; + + if (!MIME_TYPES[ext]) { + return res.status(400).json({ error: 'Unsupported image format' }); + } + const data = Buffer.from(matches[2], 'base64'); if (data.length > 2 * 1024 * 1024) { @@ -465,7 +556,12 @@ router.post('/upload-avatar', authenticateToken, async (req, res) => { } const filename = `avatar_${req.user.userId}.${ext}`; - await writeFile(join(UPLOAD_DIR, filename), data); + const filePath = join(UPLOAD_DIR, filename); + const resolvedPath = resolve(filePath); + if (!resolvedPath.startsWith(resolve(UPLOAD_DIR))) { + return res.status(403).json({ error: 'Access denied' }); + } + await writeFile(resolvedPath, data); await query('UPDATE users SET avatar = ? WHERE id = ?', [filename, req.user.userId]); @@ -514,11 +610,16 @@ router.get('/avatar/:userId', async (req, res) => { return res.status(404).json({ error: 'No avatar found' }); } + const resolvedPath = resolve(join(UPLOAD_DIR, avatarFile)); + if (!resolvedPath.startsWith(UPLOAD_DIR)) { + return res.status(403).json({ error: 'Access denied' }); + } + const mimeType = MIME_TYPES[extname(avatarFile).toLowerCase().slice(1)] || 'application/octet-stream'; res.set('Content-Type', mimeType); res.set('Cache-Control', 'private, max-age=3600'); - res.sendFile(join(UPLOAD_DIR, avatarFile)); + res.sendFile(resolvedPath); } catch (err) { if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') { return res.status(401).json({ error: 'Invalid or expired token' }); @@ -533,7 +634,7 @@ router.post('/logout', (req, res) => { res.json({ message: 'Logged out' }); }); -router.get('/export-data', authenticateToken, async (req, res) => { +router.get('/export-data', authenticateToken, sensitiveLimiter, async (req, res) => { try { const userId = req.user.userId; const pteroId = req.user.pteroId; diff --git a/routes/notifications.js b/routes/notifications.js index 1e0e245..7f17a64 100644 --- a/routes/notifications.js +++ b/routes/notifications.js @@ -35,7 +35,7 @@ router.get('/unread-count', authenticateToken, async (req, res) => { router.patch('/:id/read', authenticateToken, async (req, res) => { try { const id = parseInt(req.params.id, 10); - if (isNaN(id)) return res.status(400).json({ error: 'Invalid notification ID' }); + if (isNaN(id) || id < 1) return res.status(400).json({ error: 'Invalid notification ID' }); await markAsRead(id, req.user.userId); res.json({ success: true }); } catch (err) { diff --git a/routes/servers.js b/routes/servers.js index bcdb575..c3bb030 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { authenticateToken } from '../middleware/auth.js'; import { getServersByUser, @@ -19,6 +20,46 @@ import { createNotification } from '../services/notification.js'; const router = Router(); +const createServerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: 'Server creation limit reached. Max 3 per hour.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const renameLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 10, + message: { error: 'Too many rename requests. Max 10 per hour.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const renewLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + message: { error: 'Too many renew requests. Max 5 per hour.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const reinstallLimiter = rateLimit({ + windowMs: 24 * 60 * 60 * 1000, + max: 2, + message: { error: 'Too many reinstall requests. Max 2 per day.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const powerLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 20, + message: { error: 'Too many power actions. Max 20 per minute.' }, + standardHeaders: true, + legacyHeaders: false, +}); + router.get('/list', authenticateToken, async (req, res) => { try { const pteroId = req.user.pteroId; @@ -74,6 +115,58 @@ router.get('/list', authenticateToken, async (req, res) => { } }); +router.get('/nests', authenticateToken, async (req, res) => { + try { + const dbNests = await query('SELECT ptero_nest_id, name, logo, description FROM nests'); + const nestIds = dbNests.map(n => n.ptero_nest_id); + + const eggs = await getAllEggs(nestIds); + const nestMap = {}; + for (const n of dbNests) { + nestMap[n.ptero_nest_id] = n; + } + + const eggResources = await query('SELECT ptero_nest_id, ptero_egg_id, logo, cpu_limit, memory_limit, disk_limit FROM egg_resources'); + const eggResMap = {}; + for (const r of eggResources) { + eggResMap[`${r.ptero_nest_id}-${r.ptero_egg_id}`] = r; + } + + const result = []; + const nestEggs = {}; + for (const { nest, egg } of eggs) { + if (!nestEggs[nest]) nestEggs[nest] = []; + const key = `${nest}-${egg.id}`; + const res = eggResMap[key] || {}; + nestEggs[nest].push({ + eggId: egg.id, + name: egg.name, + description: egg.description || '', + dockerImages: egg.docker_images || {}, + logo: res.logo || null, + cpu_limit: res.cpu_limit ?? null, + memory_limit: res.memory_limit ?? null, + disk_limit: res.disk_limit ?? null, + }); + } + + for (const n of dbNests) { + result.push({ + pteroNestId: n.ptero_nest_id, + name: n.name, + logo: n.logo || null, + description: n.description || '', + eggs: nestEggs[n.ptero_nest_id] || [], + }); + } + + res.json({ nests: result }); + } catch (err) { + console.error('Get nests error:', err.message); + res.status(500).json({ error: 'Failed to fetch nests: ' + err.message }); + } +}); + router.get('/eggs', authenticateToken, async (req, res) => { try { const dbNests = await query('SELECT ptero_nest_id, name FROM nests'); @@ -107,9 +200,9 @@ router.get('/eggs', authenticateToken, async (req, res) => { } }); -router.post('/create', authenticateToken, async (req, res) => { +router.post('/create', authenticateToken, createServerLimiter, async (req, res) => { try { - const { name, nestId, eggId, environment, capToken } = req.body; + const { name, nestId, eggId, environment, capToken, dockerImage: reqDockerImage } = req.body; const pteroId = req.user.pteroId; const userCheck = await query('SELECT restricted FROM users WHERE id = ?', [req.user.userId]); @@ -117,6 +210,10 @@ router.post('/create', authenticateToken, async (req, res) => { return res.status(403).json({ error: 'Your account is restricted. Server creation is disabled.' }); } + if (typeof name !== 'string') { + return res.status(400).json({ error: 'Server name must be a string' }); + } + if (!name || !nestId || !eggId) { return res.status(400).json({ error: 'Name, nest ID and egg ID are required' }); } @@ -125,6 +222,10 @@ router.post('/create', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'Server name must be between 1 and 255 characters' }); } + if (!/^[a-zA-Z0-9 _.-]+$/.test(name)) { + return res.status(400).json({ error: 'Server name contains invalid characters' }); + } + if (!await verifyCap(capToken)) { return res.status(400).json({ error: 'Please complete the security check' }); } @@ -135,7 +236,16 @@ router.post('/create', authenticateToken, async (req, res) => { } const egg = await getEgg(nestId, eggId); - const dockerImage = Object.values(egg.docker_images)[0] || Object.keys(egg.docker_images)[0]; + let dockerImage; + if (reqDockerImage && egg.docker_images) { + if (egg.docker_images[reqDockerImage]) { + dockerImage = egg.docker_images[reqDockerImage]; + } else { + dockerImage = reqDockerImage; + } + } else { + dockerImage = Object.values(egg.docker_images || {})[0] || Object.keys(egg.docker_images || {})[0]; + } const eggVars = await query(`SELECT env_variable, default_value FROM ${PANEL_DB_NAME}.egg_variables WHERE egg_id = ?`, [eggId]); @@ -222,7 +332,7 @@ router.get('/details/:id', authenticateToken, async (req, res) => { } }); -router.post('/renew/:id', authenticateToken, async (req, res) => { +router.post('/renew/:id', authenticateToken, renewLimiter, async (req, res) => { try { const serverId = parseInt(req.params.id, 10); if (isNaN(serverId)) { @@ -292,7 +402,7 @@ router.post('/renew/:id', authenticateToken, async (req, res) => { } }); -router.patch('/:id', authenticateToken, async (req, res) => { +router.patch('/:id', authenticateToken, renameLimiter, async (req, res) => { try { const { name } = req.body; const serverId = parseInt(req.params.id, 10); @@ -301,12 +411,15 @@ router.patch('/:id', authenticateToken, async (req, res) => { } const pteroId = req.user.pteroId; - if (!name || typeof name !== 'string' || name.trim().length === 0) { + if (typeof name !== 'string' || name.trim().length === 0) { return res.status(400).json({ error: 'Server name is required' }); } - if (name.length > 255) { + if (name.trim().length > 255) { return res.status(400).json({ error: 'Server name must be 255 characters or less' }); } + if (!/^[a-zA-Z0-9 _.-]+$/.test(name.trim())) { + return res.status(400).json({ error: 'Server name contains invalid characters' }); + } const servers = await getServersByUser(pteroId); const owned = servers.find(s => s.id === serverId); @@ -324,7 +437,7 @@ router.patch('/:id', authenticateToken, async (req, res) => { } }); -router.post('/:id/reinstall', authenticateToken, async (req, res) => { +router.post('/:id/reinstall', authenticateToken, reinstallLimiter, async (req, res) => { try { const serverId = parseInt(req.params.id, 10); if (isNaN(serverId)) { @@ -446,12 +559,17 @@ router.get('/resources/:identifier', authenticateToken, async (req, res) => { } }); -router.post('/power/:identifier', authenticateToken, async (req, res) => { +router.post('/power/:identifier', authenticateToken, powerLimiter, async (req, res) => { try { const { identifier } = req.params; const { signal } = req.body; const userId = req.user.userId; + const VALID_SIGNALS = ['start', 'stop', 'restart', 'kill']; + if (!signal || typeof signal !== 'string' || !VALID_SIGNALS.includes(signal)) { + return res.status(400).json({ error: 'Invalid power signal. Valid signals: start, stop, restart, kill' }); + } + const users = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [userId]); if (!users[0]?.ptero_client_api_key) { return res.status(400).json({ error: 'No Pyrodactyl API key configured' }); @@ -501,6 +619,10 @@ router.put('/client-api-key', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'API key is required' }); } + if (apiKey.trim().length > 255) { + return res.status(400).json({ error: 'API key must be 255 characters or less' }); + } + await query('UPDATE users SET ptero_client_api_key = ? WHERE id = ?', [apiKey.trim(), userId]); await logActivity(req.user.userId, 'api_key_updated', 'Updated Pyrodactyl API key'); diff --git a/server.js b/server.js index ad69275..aa7203c 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,18 @@ import { readFile } from 'fs/promises'; const __dirname = dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: resolve(__dirname, '.env') }); +const REQUIRED_ENV_VARS = ['JWT_SECRET', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'CAP_SECRET', 'CAP_ENDPOINT', 'COOKIE_SECRET']; +const missing = REQUIRED_ENV_VARS.filter(v => !process.env[v]); +if (missing.length > 0) { + console.error(`Missing required environment variables: ${missing.join(', ')}`); + process.exit(1); +} + +if (process.env.JWT_SECRET && /[\$\(\)]/.test(process.env.JWT_SECRET)) { + console.error('JWT_SECRET contains unresolved shell expansion characters. Generate a proper random key.'); + process.exit(1); +} + import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; @@ -13,14 +25,15 @@ import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import jwt from 'jsonwebtoken'; import path from 'path'; +import crypto from 'crypto'; import authRoutes from './routes/auth.js'; import serverRoutes from './routes/servers.js'; import adminRoutes from './routes/admin.js'; import notificationRoutes from './routes/notifications.js'; -import { startScheduler } from './services/scheduler.js'; +import { startScheduler, stopScheduler } from './services/scheduler.js'; import { migrate } from './config/migrate.js'; -import { query } from './config/db.js'; +import { query, closePool, getPoolStatus } from './config/db.js'; import { getRecentActivity } from './services/activity.js'; const app = express(); @@ -35,11 +48,43 @@ async function getPort() { } const PORT = process.env.PORT || await getPort(); +const SHUTDOWN_TIMEOUT = parseInt(process.env.SHUTDOWN_TIMEOUT, 10) || 10000; const trustProxy = process.env.NODE_ENV === 'production'; app.set('trust proxy', trustProxy ? 1 : 0); +app.use((req, res, next) => { + req.requestId = crypto.randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + res.setHeader('X-Content-Type-Options', 'nosniff'); + next(); +}); + +const activeRequests = new Map(); + +app.use((req, res, next) => { + const ip = req.ip || req.socket.remoteAddress || '0.0.0.0'; + const count = (activeRequests.get(ip) || 0) + 1; + if (count > 20) { + return res.status(429).json({ error: 'Too many concurrent requests' }); + } + activeRequests.set(ip, count); + res.on('finish', () => { + const c = activeRequests.get(ip); + if (c && c <= 1) activeRequests.delete(ip); + else if (c) activeRequests.set(ip, c - 1); + }); + next(); +}); + +app.use((req, res, next) => { + res.setTimeout(30000, () => { + res.status(503).json({ error: 'Request timeout', requestId: req.requestId }); + }); + next(); +}); + app.use(helmet({ contentSecurityPolicy: { directives: { @@ -53,6 +98,7 @@ app.use(helmet({ workerSrc: ["'self'", "blob:"], frameSrc: ["https://cap.zero-host.org"], objectSrc: ["'none'"], + upgradeInsecureRequests: [], }, }, hsts: { @@ -76,8 +122,41 @@ app.use(cors({ })); app.use(express.json({ limit: '1mb' })); +app.use(express.urlencoded({ extended: false, limit: '1mb' })); + +app.use((req, res, next) => { + if (req.path.startsWith('/api/') && !['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + return res.status(405).json({ error: 'Method not allowed', requestId: req.requestId }); + } + next(); +}); + +app.use((req, res, next) => { + if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.path.startsWith('/api/')) { + const ct = req.headers['content-type'] || ''; + if (!ct.startsWith('application/json') && !ct.startsWith('application/x-www-form-urlencoded') && !ct.startsWith('multipart/form-data')) { + return res.status(415).json({ error: 'Unsupported content type. Use application/json.', requestId: req.requestId }); + } + } + next(); +}); + +app.use((err, req, res, next) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + return res.status(400).json({ error: 'Invalid JSON in request body', requestId: req.requestId }); + } + next(); +}); app.use(cookieParser(process.env.COOKIE_SECRET)); +app.use((req, res, next) => { + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.setHeader('X-Robots-Tag', 'noindex, nofollow'); + res.setHeader('X-DNS-Prefetch-Control', 'off'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + next(); +}); + const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, @@ -96,10 +175,49 @@ const apiLimiter = rateLimit({ trustProxy: trustProxy ? 1 : 0, }); +const activityLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + message: { error: 'Too many requests' }, + standardHeaders: true, + legacyHeaders: false, + trustProxy: trustProxy ? 1 : 0, +}); + app.use('/api/auth/login', authLimiter); app.use('/api/auth/register', authLimiter); +app.use('/api/activity', activityLimiter); +app.use('/api/notifications', authLimiter); +app.use('/api/servers', apiLimiter); app.use('/api', apiLimiter); +const userRateMap = new Map(); +const USER_RATE_MAX = 200; +const USER_RATE_WINDOW = 60000; + +app.use('/api', (req, res, next) => { + const userId = req.user?.userId; + if (!userId) return next(); + const now = Date.now(); + let entry = userRateMap.get(userId); + if (!entry || now - entry.windowStart > USER_RATE_WINDOW) { + entry = { count: 0, windowStart: now }; + userRateMap.set(userId, entry); + } + entry.count++; + if (entry.count > USER_RATE_MAX) { + return res.status(429).json({ error: 'User rate limit exceeded. Slow down.', requestId: req.requestId }); + } + next(); +}); + +setInterval(() => { + const cutoff = Date.now() - USER_RATE_WINDOW; + for (const [uid, entry] of userRateMap) { + if (entry.windowStart < cutoff) userRateMap.delete(uid); + } +}, 60000); + app.use('/api/auth', authRoutes); app.use('/api/servers', serverRoutes); app.use('/api/admin', adminRoutes); @@ -130,7 +248,7 @@ app.get('/api/activity', async (req, res) => { if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') { return res.status(401).json({ error: 'Invalid or expired token' }); } - console.error('Activity route error:', err.message, err.stack?.split('\n')[1]); + console.error(`[${req.requestId}] Activity route error:`, err.message, err.stack?.split('\n')[1]); res.status(500).json({ error: 'Failed to fetch activities' }); } }); @@ -138,9 +256,23 @@ app.get('/api/activity', async (req, res) => { app.get('/api/health', async (req, res) => { try { await query('SELECT 1'); - res.json({ status: 'ok', db: 'connected', timestamp: new Date().toISOString() }); + const poolStats = await getPoolStatus(); + const memUsage = process.memoryUsage(); + res.json({ + status: 'ok', + db: 'connected', + pool: poolStats, + memory: { + rss: Math.round(memUsage.rss / 1024 / 1024) + 'MB', + heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) + 'MB', + heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024) + 'MB', + }, + uptime: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + requestId: req.requestId, + }); } catch { - res.status(503).json({ status: 'error', db: 'disconnected', timestamp: new Date().toISOString() }); + res.status(503).json({ status: 'error', db: 'disconnected', timestamp: new Date().toISOString(), requestId: req.requestId }); } }); @@ -164,26 +296,56 @@ app.get('*', (req, res) => { }); app.use((err, req, res, _next) => { - console.error('Unhandled error:', err); - res.status(500).json({ error: 'Internal server error' }); + console.error(`[${req.requestId}] Unhandled error:`, err); + const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message; + res.status(err.status || 500).json({ error: message, requestId: req.requestId }); +}); + +migrate().then(() => { + const server = app.listen(PORT, () => { + console.log(`ZeroHost Dashboard running on port ${PORT}`); + startScheduler(); + }); + + function shutdown(signal) { + console.log(`\nReceived ${signal}, shutting down gracefully...`); + stopScheduler(); + server.close(() => { + closePool(); + process.exit(0); + }); + setTimeout(() => { + console.error('Forced shutdown after timeout'); + process.exit(1); + }, SHUTDOWN_TIMEOUT); + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +}).catch(err => { + console.error('Migration failed:', err); + process.exit(1); }); process.on('unhandledRejection', (reason) => { console.error('Unhandled Promise rejection:', reason); - process.exit(1); }); process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); - process.exit(1); }); -migrate().then(() => { - app.listen(PORT, () => { - console.log(`ZeroHost Dashboard running on port ${PORT}`); - startScheduler(); - }); -}).catch(err => { - console.error('Migration failed:', err); - process.exit(1); +process.on('warning', (warning) => { + if (warning.name === 'MaxListenersExceededWarning') { + console.error('Memory leak detected:', warning.message); + } }); + +setInterval(() => { + const mem = process.memoryUsage(); + if (mem.heapUsed > 500 * 1024 * 1024) { + console.error(`High memory usage warning: ${Math.round(mem.heapUsed / 1024 / 1024)}MB heap used`); + } +}, 60000); + + diff --git a/services/pyrodactyl.js b/services/pyrodactyl.js index a3d5b69..a4e23b0 100644 --- a/services/pyrodactyl.js +++ b/services/pyrodactyl.js @@ -4,6 +4,11 @@ const FETCH_TIMEOUT = 15000; const CACHE_TTL = 5 * 60 * 1000; const CACHE_MAX_SIZE = 50; const nodeCache = new Map(); +let cacheCleanupTimer = null; + +function recordPteroError(errMsg) { + console.warn(`Pterodactyl API error: ${errMsg}`); +} async function fetchWithTimeout(url, options = {}, timeout = FETCH_TIMEOUT) { const controller = new AbortController(); @@ -22,14 +27,45 @@ const headers = { 'Content-Type': 'application/json', }; +function startCacheCleanup() { + if (cacheCleanupTimer) return; + cacheCleanupTimer = setInterval(() => { + const cutoff = Date.now() - CACHE_TTL; + let cleaned = 0; + for (const [id, entry] of nodeCache) { + if (entry.ts < cutoff) { + nodeCache.delete(id); + cleaned++; + } + } + if (cleaned > 0) { + console.log(`Cleaned ${cleaned} stale cache entries (${nodeCache.size} remaining)`); + } + }, CACHE_TTL); +} + +startCacheCleanup(); + async function pteroFetch(path, options = {}) { const url = `${PTERO_URL}/api/application${path}`; const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { - const res = await fetchWithTimeout(url, { - ...options, - headers: { ...headers, ...options.headers }, - }); + let res; + try { + res = await fetchWithTimeout(url, { + ...options, + headers: { ...headers, ...options.headers }, + }); + } catch (err) { + recordPteroError(err.message); + if (attempt < maxRetries) { + const wait = Math.min(1000 * Math.pow(2, attempt - 1), 8000); + console.warn(`pteroFetch network error (${err.message}), retry ${attempt}/${maxRetries} after ${wait}ms`); + await new Promise(r => setTimeout(r, wait)); + continue; + } + throw err; + } if (res.status === 204) return null; if (res.status === 429 && attempt < maxRetries) { const wait = Math.min(1000 * Math.pow(2, attempt - 1), 8000); @@ -37,12 +73,12 @@ async function pteroFetch(path, options = {}) { await new Promise(r => setTimeout(r, wait)); continue; } - const data = await res.json(); if (!res.ok) { - const detail = data?.errors?.[0]?.detail || JSON.stringify(data); - throw new Error(detail); + recordPteroError(`${res.status} ${res.statusText}`); + const text = await res.text(); + throw new Error(`Pterodactyl API error ${res.status}: ${text.slice(0, 200)}`); } - return data; + return await res.json(); } } @@ -108,7 +144,8 @@ export async function getAllServers() { let page = 1; let hasMore = true; - while (hasMore) { + const MAX_PAGES = 20; + while (hasMore && page <= MAX_PAGES) { const data = await pteroFetch(`/servers?page=${page}&per_page=50`); const servers = data.data.map(s => s.attributes); allServers = allServers.concat(servers); @@ -143,7 +180,8 @@ export async function getServersByUser(userId) { let page = 1; let hasMore = true; - while (hasMore) { + const MAX_PAGES = 20; + while (hasMore && page <= MAX_PAGES) { const data = await pteroFetch(`/servers?page=${page}&per_page=50`); const servers = data.data.map(s => s.attributes).filter(s => s.user === userId); allServers = allServers.concat(servers); @@ -275,7 +313,8 @@ export async function getPergoServerIdsByEgg(nestId, eggId) { const ids = []; let page = 1; let hasMore = true; - while (hasMore) { + const MAX_PAGES = 20; + while (hasMore && page <= MAX_PAGES) { const data = await pteroFetch(`/servers?page=${page}&per_page=100`); const servers = data.data.map(s => s.attributes); for (const s of servers) { diff --git a/services/scheduler.js b/services/scheduler.js index c615387..435622e 100644 --- a/services/scheduler.js +++ b/services/scheduler.js @@ -33,10 +33,25 @@ function msUntilMidnight() { return midnight.getTime() - now.getTime(); } +let schedulerTimer = null; +let schedulerRunning = false; + +export function stopScheduler() { + schedulerRunning = false; + if (schedulerTimer) { + clearTimeout(schedulerTimer); + schedulerTimer = null; + } +} + export function startScheduler() { + if (schedulerRunning) return; + schedulerRunning = true; suspendExpiredServers().catch(err => console.error('Initial scheduler run failed:', err.message)); const tick = () => { - setTimeout(async () => { + if (!schedulerRunning) return; + schedulerTimer = setTimeout(async () => { + if (!schedulerRunning) return; try { await suspendExpiredServers(); } catch (err) { @@ -47,4 +62,33 @@ export function startScheduler() { }; tick(); console.log('Server lifetime scheduler started'); + + cleanupOldNotifications().catch(err => console.error('Initial notification cleanup failed:', err.message)); + cleanupOldActivityLogs().catch(err => console.error('Initial activity log cleanup failed:', err.message)); +} + +async function cleanupOldNotifications() { + try { + const result = await query( + "DELETE FROM notifications WHERE created_at < NOW() - INTERVAL 90 DAY AND is_read = 1" + ); + if (result.affectedRows > 0) { + console.log(`Cleaned up ${result.affectedRows} old read notification(s)`); + } + } catch (err) { + console.error('Notification cleanup error:', err.message); + } +} + +async function cleanupOldActivityLogs() { + try { + const result = await query( + "DELETE FROM activity_log WHERE created_at < NOW() - INTERVAL 90 DAY" + ); + if (result.affectedRows > 0) { + console.log(`Cleaned up ${result.affectedRows} old activity log(s)`); + } + } catch (err) { + console.error('Activity log cleanup error:', err.message); + } }