From 471e8114c98a73024d80301feb2c05907e5f6333 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:29:48 +0200 Subject: [PATCH 01/51] fix(critical): prevent SQL injection via PANEL_DB_NAME by backtick-quoting and centralizing definition --- config/pyrodactyl.js | 3 ++- routes/admin.js | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/pyrodactyl.js b/config/pyrodactyl.js index cbe1cba..f03a6ed 100644 --- a/config/pyrodactyl.js +++ b/config/pyrodactyl.js @@ -1,6 +1,7 @@ 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').replace(/[^a-zA-Z0-9_]/g, ''); +const _rawPanelDb = (process.env.PANEL_DB_NAME || 'panel').replace(/[^a-zA-Z0-9_]/g, ''); +export const PANEL_DB_NAME = '`' + _rawPanelDb + '`'; export const SERVER_LIMITS = { memory: 512, diff --git a/routes/admin.js b/routes/admin.js index 801e1f5..2d8f988 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -4,7 +4,7 @@ import argon2 from 'argon2'; import jwt from 'jsonwebtoken'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { query } from '../config/db.js'; -import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser, updatePteroServerBuild, getPergoServerIdsByEgg, getAllNodes, getNodeDetail, getNodeAllocations, getNodeServers } from '../services/pyrodactyl.js'; +import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser, updatePteroServerBuild, getPergoServerIdsByEgg, getAllNodes, getNodeDetail, getNodeAllocations, getNodeServers, PANEL_DB_NAME } from '../services/pyrodactyl.js'; import { verifyCap } from '../config/cap.js'; import { logActivity } from '../services/activity.js'; import { createNotification } from '../services/notification.js'; @@ -12,7 +12,6 @@ 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').replace(/[^a-zA-Z0-9_]/g, ''); const adminLoginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, From e47fc40002f7bf2c3db6cacb913212cd37a5063b Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:30:21 +0200 Subject: [PATCH 02/51] fix(critical): remove 'unsafe-eval' from CSP to prevent XSS via code injection --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index 13bd90f..601ab2b 100644 --- a/server.js +++ b/server.js @@ -90,7 +90,7 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], scriptSrcAttr: ["'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"], fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdn.jsdelivr.net"], From eecd21a8d75a348ef89a3f798eecef769723489e Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:30:59 +0200 Subject: [PATCH 03/51] fix(critical): prevent attacker-controlled lookup in WebAuthn challenge map by keying on userId/sessionToken instead of client-supplied challenge --- routes/passkeys.js | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/routes/passkeys.js b/routes/passkeys.js index ac8412b..d4b1354 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -32,8 +32,14 @@ function getWebAuthnConfig(req) { }; } +// Store challenges keyed by userId (register) or sessionToken (login) +// to prevent attacker-controlled lookup via clientDataJSON.challenge const challengeMap = new Map(); +function generateSessionToken() { + return crypto.randomBytes(32).toString('hex'); +} + function getClientIp(req) { const forwarded = req.headers['x-forwarded-for']; if (forwarded) return forwarded.split(',')[0].trim(); @@ -77,7 +83,7 @@ router.post('/passkeys/register/begin', authenticateToken, async (req, res) => { }, }); - challengeMap.set(options.challenge, { + challengeMap.set(`register:${userId}`, { challenge: options.challenge, timestamp: Date.now(), userId, @@ -99,18 +105,17 @@ router.post('/passkeys/register/complete', authenticateToken, async (req, res) = return res.status(400).json({ error: 'Registration response is required' }); } - const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge; - const expectedChallenge = challengeMap.get(challengeFromResponse); - if (!expectedChallenge) { + const stored = challengeMap.get(`register:${userId}`); + if (!stored) { return res.status(400).json({ error: 'No registration in progress. Please try again.' }); } - challengeMap.delete(challengeFromResponse); + challengeMap.delete(`register:${userId}`); const { rpID, origin } = getWebAuthnConfig(req); const verification = await verifyRegistrationResponse({ response, - expectedChallenge: expectedChallenge.challenge, + expectedChallenge: stored.challenge, expectedOrigin: origin, expectedRPID: rpID, }); @@ -183,13 +188,14 @@ router.post('/passkeys/login/begin', async (req, res) => { userVerification: 'preferred', }); - challengeMap.set(options.challenge, { + const sessionToken = generateSessionToken(); + challengeMap.set(`login:${sessionToken}`, { challenge: options.challenge, timestamp: Date.now(), userId, }); - res.json({ options, userId }); + res.json({ options, userId, sessionToken }); } catch (err) { console.error('Passkey login begin error:', err.message); res.status(500).json({ error: 'Failed to initiate passkey login' }); @@ -198,18 +204,20 @@ router.post('/passkeys/login/begin', async (req, res) => { router.post('/passkeys/login/complete', async (req, res) => { try { - const { response } = req.body; + const { response, sessionToken } = req.body; if (!response) { return res.status(400).json({ error: 'Response is required' }); } + if (!sessionToken) { + return res.status(400).json({ error: 'Session token is required' }); + } - const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge; - const expectedChallenge = challengeMap.get(challengeFromResponse); - if (!expectedChallenge) { + const stored = challengeMap.get(`login:${sessionToken}`); + if (!stored) { return res.status(400).json({ error: 'No login in progress. Please try again.' }); } - challengeMap.delete(challengeFromResponse); + challengeMap.delete(`login:${sessionToken}`); const passkeys = await query( 'SELECT id, credential_id, public_key, counter, transports, user_id FROM passkeys WHERE credential_id = ?', @@ -225,7 +233,7 @@ router.post('/passkeys/login/complete', async (req, res) => { const { rpID, origin } = getWebAuthnConfig(req); const verification = await verifyAuthenticationResponse({ response, - expectedChallenge: expectedChallenge.challenge, + expectedChallenge: stored.challenge, expectedOrigin: origin, expectedRPID: rpID, credential: { From 3e4bfd7c7908c259506824e0023457c930cbe615 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:31:18 +0200 Subject: [PATCH 04/51] fix(high): add progressive login delay to admin login to prevent brute-force attacks --- routes/admin.js | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/routes/admin.js b/routes/admin.js index 2d8f988..40a2093 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -21,6 +21,51 @@ const adminLoginLimiter = rateLimit({ legacyHeaders: false, }); +const adminLoginAttempts = new Map(); + +function getAdminLoginDelay(ip) { + const now = Date.now(); + const entry = adminLoginAttempts.get(ip); + if (!entry) return 0; + const sinceFirst = now - entry.firstAttempt; + if (sinceFirst > 15 * 60 * 1000) { + adminLoginAttempts.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 recordAdminLoginAttempt(ip, success) { + if (success) { + adminLoginAttempts.delete(ip); + return; + } + const now = Date.now(); + const entry = adminLoginAttempts.get(ip); + if (entry) { + entry.count++; + } else { + adminLoginAttempts.set(ip, { count: 1, firstAttempt: now }); + } +} + +setInterval(() => { + const cutoff = Date.now() - 15 * 60 * 1000; + for (const [ip, entry] of adminLoginAttempts) { + if (entry.firstAttempt < cutoff) adminLoginAttempts.delete(ip); + } +}, 60 * 1000); + +function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) return forwarded.split(',')[0].trim(); + return req.ip || req.socket.remoteAddress || '0.0.0.0'; +} + router.post('/login', adminLoginLimiter, async (req, res) => { try { const { email, password, capToken } = req.body; @@ -32,28 +77,41 @@ router.post('/login', adminLoginLimiter, async (req, res) => { return res.status(400).json({ error: 'Invalid input types' }); } + const ip = getClientIp(req); + const delay = getAdminLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + if (!await verifyCap(capToken)) { + recordAdminLoginAttempt(ip, false); return res.status(400).json({ error: 'Please complete the security check' }); } const users = await query('SELECT * FROM users WHERE email = ?', [email]); if (users.length === 0) { + recordAdminLoginAttempt(ip, false); return res.status(401).json({ error: 'Invalid credentials' }); } const user = users[0]; if (user.auth_restricted) { + recordAdminLoginAttempt(ip, false); return res.status(403).json({ error: 'Your account has been restricted. Contact support for assistance.' }); } if (!user.is_admin) { + recordAdminLoginAttempt(ip, false); return res.status(403).json({ error: 'Access denied. Admin privileges required.' }); } const valid = await argon2.verify(user.password_hash, password, { type: argon2.argon2id }); if (!valid) { + recordAdminLoginAttempt(ip, false); return res.status(401).json({ error: 'Invalid credentials' }); } + recordAdminLoginAttempt(ip, true); + const token = jwt.sign( { userId: user.id, email: user.email, username: user.username, pteroId: user.ptero_user_id, isAdmin: true, restricted: false, tokenVersion: user.token_version }, JWT_SECRET, From ea4ef153437ba926f2ecaf146108e995185bff14 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:31:29 +0200 Subject: [PATCH 05/51] fix(high): bump token_version on email change to invalidate existing sessions --- routes/auth.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/routes/auth.js b/routes/auth.js index 48800c0..7788242 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -488,11 +488,14 @@ router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, re return res.status(500).json({ error: 'Failed to update email on panel' }); } - // Update local DB - await query('UPDATE users SET email = ? WHERE id = ?', [newEmail, userId]); + // Update local DB and invalidate existing sessions + await query('UPDATE users SET email = ?, token_version = token_version + 1 WHERE id = ?', [newEmail, userId]); await logActivity(userId, 'email_changed', `Changed email to ${newEmail}`); + // Fetch updated token_version + const [updatedUser] = await query('SELECT token_version FROM users WHERE id = ?', [userId]); + // Generate new token with updated email const token = generateToken({ userId: user.id, @@ -501,7 +504,7 @@ router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, re pteroId: user.ptero_user_id, isAdmin: !!user.is_admin, restricted: !!user.restricted, - tokenVersion: user.token_version, + tokenVersion: updatedUser.token_version, }); res.json({ From 1db8c9ba9d891aeb957c926a24c50738b3acf88c Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:32:00 +0200 Subject: [PATCH 06/51] fix(high): apply progressive login delay only after CAPTCHA/password validation to prevent resource exhaustion --- routes/auth.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/routes/auth.js b/routes/auth.js index 7788242..1aeb374 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -114,10 +114,6 @@ router.post('/register', async (req, res) => { 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' }); @@ -143,6 +139,11 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Password must be between 8 and 128 characters' }); } + const delay = getLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + if (!await verifyCap(capToken)) { recordLoginAttempt(ip, false); return res.status(400).json({ error: 'Please complete the security check' }); @@ -308,10 +309,6 @@ router.post('/login', async (req, res) => { } const ip = getClientIp(req); - const delay = getLoginDelay(ip); - if (delay > 0) { - await new Promise(r => setTimeout(r, delay)); - } // Cap verification if (!await verifyCap(capToken)) { @@ -325,6 +322,12 @@ router.post('/login', async (req, res) => { return res.status(403).json({ error: 'VPNs and proxies are not allowed. Please disable them to sign in.' }); } + // Progressive delay applied only after validation to prevent resource exhaustion + const delay = getLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + const users = await query('SELECT * FROM users WHERE email = ?', [email]); if (users.length === 0) { recordLoginAttempt(ip, false); From 1e90bc497b3f45bb8f7a73d77590efdc06953c7a Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:32:34 +0200 Subject: [PATCH 07/51] fix(high): add ownership verification for resources and power endpoints to prevent unauthorized server access --- routes/servers.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/routes/servers.js b/routes/servers.js index de95589..fc81009 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -531,11 +531,24 @@ router.get('/overview', authenticateToken, async (req, res) => { } }); +async function verifyServerOwnership(userId, identifier) { + try { + const servers = await getServersByUser(userId); + return servers.some(s => s.identifier === identifier); + } catch { + return false; + } +} + router.get('/resources/:identifier', authenticateToken, async (req, res) => { try { const { identifier } = req.params; const userId = req.user.userId; + if (!await verifyServerOwnership(userId, identifier)) { + return res.status(403).json({ error: 'You do not own this server' }); + } + const users = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [userId]); if (users.length === 0 || !users[0].ptero_client_api_key) { return res.json({ resources: null, error: 'No Pyrodactyl API key configured. Set one in Account settings.' }); @@ -568,6 +581,10 @@ router.post('/power/:identifier', authenticateToken, powerLimiter, async (req, r const { signal } = req.body; const userId = req.user.userId; + if (!await verifyServerOwnership(userId, identifier)) { + return res.status(403).json({ error: 'You do not own this server' }); + } + 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' }); From e4fe5488ad4f55f595b3c69af7b3658dd8fe42d4 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:32:49 +0200 Subject: [PATCH 08/51] fix(high): invalidate all sessions on logout by bumping token_version --- routes/auth.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/routes/auth.js b/routes/auth.js index 1aeb374..ba0f4be 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -586,7 +586,13 @@ router.post('/delete-account', authenticateToken, sensitiveLimiter, async (req, } }); -router.post('/logout', (req, res) => { +router.post('/logout', authenticateToken, async (req, res) => { + try { + // Bump token_version to invalidate all existing sessions + await query('UPDATE users SET token_version = token_version + 1 WHERE id = ?', [req.user.userId]); + } catch (err) { + console.error('Logout token_version bump failed:', err.message); + } res.clearCookie('token', { httpOnly: true, secure: true, sameSite: 'strict' }); res.json({ message: 'Logged out' }); }); From 7768db87ce3af273934b2010c2d73a3fe085b3b5 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:33:12 +0200 Subject: [PATCH 09/51] fix(medium): sanitize activity log details to prevent log injection and HTML injection --- routes/admin.js | 2 +- services/activity.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/routes/admin.js b/routes/admin.js index 40a2093..d8ebb06 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -189,7 +189,7 @@ router.post('/servers/:id/suspend', authenticateToken, requireAdmin, async (req, } const reason = (req.body.reason && typeof req.body.reason === 'string') - ? req.body.reason.slice(0, 500) : 'Suspended by an Administrator. Please contact support.'; + ? req.body.reason.replace(/[<>"']/g, '').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]); diff --git a/services/activity.js b/services/activity.js index 6a53695..6f5e076 100644 --- a/services/activity.js +++ b/services/activity.js @@ -2,9 +2,10 @@ import { query } from '../config/db.js'; export async function logActivity(userId, action, details = '', serverId = null) { try { + const safeDetails = String(details).replace(/[<>"']/g, '').slice(0, 255); await query( 'INSERT INTO activity_log (user_id, action, details, server_id) VALUES (?, ?, ?, ?)', - [userId, action, details, serverId] + [userId, action, safeDetails, serverId] ); } catch (err) { console.error('Failed to log activity:', err.message); From 9306e1b1ca703c13a34ebbbb162def1ffadbf8a0 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:33:42 +0200 Subject: [PATCH 10/51] fix(medium): add rate limiting to all passkey endpoints to prevent brute-force and abuse --- routes/passkeys.js | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/routes/passkeys.js b/routes/passkeys.js index d4b1354..ef5e5f2 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { generateRegistrationOptions, verifyRegistrationResponse, @@ -14,6 +15,22 @@ import { logActivity } from '../services/activity.js'; const router = Router(); +const passkeyRegisterLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { error: 'Too many passkey registration attempts, try again later' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const passkeyLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + message: { error: 'Too many passkey login attempts, try again later' }, + standardHeaders: true, + legacyHeaders: false, +}); + const RP_NAME = 'ZeroHost'; function gravatarHash(email) { @@ -46,7 +63,7 @@ function getClientIp(req) { return req.ip || req.socket.remoteAddress || '0.0.0.0'; } -router.post('/passkeys/register/begin', authenticateToken, async (req, res) => { +router.post('/passkeys/register/begin', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const userId = req.user.userId; const { rpID, origin } = getWebAuthnConfig(req); @@ -96,7 +113,7 @@ router.post('/passkeys/register/begin', authenticateToken, async (req, res) => { } }); -router.post('/passkeys/register/complete', authenticateToken, async (req, res) => { +router.post('/passkeys/register/complete', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const userId = req.user.userId; const { response } = req.body; @@ -147,7 +164,7 @@ router.post('/passkeys/register/complete', authenticateToken, async (req, res) = } }); -router.post('/passkeys/login/begin', async (req, res) => { +router.post('/passkeys/login/begin', passkeyLoginLimiter, async (req, res) => { try { const { email } = req.body; let userId = null; @@ -202,7 +219,7 @@ router.post('/passkeys/login/begin', async (req, res) => { } }); -router.post('/passkeys/login/complete', async (req, res) => { +router.post('/passkeys/login/complete', passkeyLoginLimiter, async (req, res) => { try { const { response, sessionToken } = req.body; if (!response) { @@ -290,7 +307,7 @@ router.post('/passkeys/login/complete', async (req, res) => { } }); -router.get('/passkeys', authenticateToken, async (req, res) => { +router.get('/passkeys', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const passkeys = await query( 'SELECT id, name, transports, created_at FROM passkeys WHERE user_id = ? ORDER BY created_at DESC', @@ -304,7 +321,7 @@ router.get('/passkeys', authenticateToken, async (req, res) => { } }); -router.delete('/passkeys/:id', authenticateToken, async (req, res) => { +router.delete('/passkeys/:id', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) { From 6cb48351bef5445e03469bfc3f2e505f518de61b Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:36:52 +0200 Subject: [PATCH 11/51] fix(medium): remove verbose error messages from API responses to prevent information leakage in production --- routes/admin.js | 30 +++++++++++++++--------------- routes/passkeys.js | 4 ++-- routes/servers.js | 12 ++++++------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/routes/admin.js b/routes/admin.js index d8ebb06..409b7a5 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -201,7 +201,7 @@ router.post('/servers/:id/suspend', authenticateToken, requireAdmin, async (req, res.json({ success: true }); } catch (err) { console.error('Admin suspend error:', err.message); - res.status(500).json({ error: 'Failed to suspend server: ' + err.message }); + res.status(500).json({ error: 'Failed to suspend server' }); } }); @@ -222,7 +222,7 @@ router.post('/servers/:id/unsuspend', authenticateToken, requireAdmin, async (re res.json({ success: true }); } catch (err) { console.error('Admin unsuspend error:', err.message); - res.status(500).json({ error: 'Failed to unsuspend server: ' + err.message }); + res.status(500).json({ error: 'Failed to unsuspend server' }); } }); @@ -263,7 +263,7 @@ router.post('/servers/:id/stop', authenticateToken, requireAdmin, async (req, re res.json({ success: true }); } catch (err) { console.error('Admin stop error:', err.message); - res.status(500).json({ error: 'Failed to stop server: ' + err.message }); + res.status(500).json({ error: 'Failed to stop server' }); } }); @@ -284,7 +284,7 @@ router.post('/servers/:id/renew-now', authenticateToken, requireAdmin, async (re res.json({ success: true }); } catch (err) { console.error('Admin renew-now error:', err.message); - res.status(500).json({ error: 'Failed to expire server: ' + err.message }); + res.status(500).json({ error: 'Failed to expire server' }); } }); @@ -309,7 +309,7 @@ router.delete('/servers/:id', authenticateToken, requireAdmin, async (req, res) res.json({ success: true }); } catch (err) { console.error('Admin delete error:', err.message); - res.status(500).json({ error: 'Failed to delete server: ' + err.message }); + res.status(500).json({ error: 'Failed to delete server' }); } }); @@ -611,7 +611,7 @@ router.post('/users/:id/notify', authenticateToken, requireAdmin, async (req, re res.json({ success: true }); } catch (err) { console.error('Admin notify error:', err.message); - res.status(500).json({ error: 'Failed to send notification: ' + err.message }); + res.status(500).json({ error: 'Failed to send notification' }); } }); @@ -651,7 +651,7 @@ router.post('/notify-all', authenticateToken, requireAdmin, async (req, res) => 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 }); + res.status(500).json({ error: 'Failed to send notifications' }); } }); @@ -695,7 +695,7 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => res.json({ success: true }); } catch (err) { console.error('Admin delete user error:', err.message); - res.status(500).json({ error: 'Failed to delete user: ' + err.message }); + res.status(500).json({ error: 'Failed to delete user' }); } }); @@ -754,7 +754,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: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch nests' }); } }); @@ -766,7 +766,7 @@ router.get('/settings/nests/available', authenticateToken, requireAdmin, async ( const available = pteroNests.filter(n => !localIds.has(n.id)); res.json({ nests: available }); } catch (err) { - res.status(500).json({ error: 'Failed to fetch available nests: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch available nests' }); } }); @@ -796,7 +796,7 @@ router.post('/settings/nests', authenticateToken, requireAdmin, async (req, res) if (err.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'Nest already added' }); } - res.status(500).json({ error: 'Failed to add nest: ' + err.message }); + res.status(500).json({ error: 'Failed to add nest' }); } }); @@ -817,7 +817,7 @@ router.put('/settings/nests/:id', authenticateToken, requireAdmin, async (req, r await query(`UPDATE nests SET ${updates.join(', ')} WHERE id = ?`, params); res.json({ success: true }); } catch (err) { - res.status(500).json({ error: 'Failed to update nest: ' + err.message }); + res.status(500).json({ error: 'Failed to update nest' }); } }); @@ -850,7 +850,7 @@ router.get('/settings/nests/:nestId/eggs', authenticateToken, requireAdmin, asyn })); res.json({ eggs }); } catch (err) { - res.status(500).json({ error: 'Failed to fetch eggs: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch eggs' }); } }); @@ -904,7 +904,7 @@ router.put('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, asy } res.json({ success: true }); } catch (err) { - res.status(500).json({ error: 'Failed to save egg settings: ' + err.message }); + res.status(500).json({ error: 'Failed to save egg settings' }); } }); @@ -957,7 +957,7 @@ router.post('/settings/eggs/:nestId/:eggId/apply-all', authenticateToken, requir res.json({ success: true, updated, total: pteroIds.length }); } catch (err) { - res.status(500).json({ error: 'Failed to apply resources to all servers: ' + err.message }); + res.status(500).json({ error: 'Failed to apply resources to all servers' }); } }); diff --git a/routes/passkeys.js b/routes/passkeys.js index ef5e5f2..c4feac7 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -160,7 +160,7 @@ router.post('/passkeys/register/complete', authenticateToken, passkeyRegisterLim res.json({ verified: true }); } catch (err) { console.error('Passkey register complete error:', err.message); - res.status(400).json({ error: 'Failed to complete passkey registration: ' + err.message }); + res.status(400).json({ error: 'Failed to complete passkey registration' }); } }); @@ -303,7 +303,7 @@ router.post('/passkeys/login/complete', passkeyLoginLimiter, async (req, res) => }); } catch (err) { console.error('Passkey login complete error:', err.message); - res.status(400).json({ error: 'Failed to complete passkey login: ' + err.message }); + res.status(400).json({ error: 'Failed to complete passkey login' }); } }); diff --git a/routes/servers.js b/routes/servers.js index fc81009..d8a965b 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -166,7 +166,7 @@ router.get('/nests', authenticateToken, async (req, res) => { res.json({ nests: result }); } catch (err) { console.error('Get nests error:', err.message); - res.status(500).json({ error: 'Failed to fetch nests: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch nests' }); } }); @@ -322,7 +322,7 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi if (err.message.includes('NoViableAllocationException')) { return res.status(400).json({ error: 'No available allocations found' }); } - res.status(500).json({ error: 'Failed to create server: ' + err.message }); + res.status(500).json({ error: 'Failed to create server' }); } }); @@ -416,7 +416,7 @@ router.post('/renew/:id', authenticateToken, requireNotRestricted, requireOwners res.json({ serverMeta: updated[0] }); } catch (err) { console.error('Renew server error:', err.message); - res.status(500).json({ error: 'Failed to renew server: ' + err.message }); + res.status(500).json({ error: 'Failed to renew server' }); } }); @@ -444,7 +444,7 @@ router.patch('/:id', authenticateToken, requireNotRestricted, requireOwnership(' res.json({ success: true }); } catch (err) { console.error('Rename server error:', err.message); - res.status(500).json({ error: 'Failed to rename server: ' + err.message }); + res.status(500).json({ error: 'Failed to rename server' }); } }); @@ -461,7 +461,7 @@ router.post('/:id/reinstall', authenticateToken, requireNotRestricted, requireOw res.json({ success: true }); } catch (err) { console.error('Reinstall server error:', err.message); - res.status(500).json({ error: 'Failed to reinstall server: ' + err.message }); + res.status(500).json({ error: 'Failed to reinstall server' }); } }); @@ -486,7 +486,7 @@ router.delete('/:id', authenticateToken, requireNotRestricted, requireOwnership( res.json({ success: true }); } catch (err) { console.error('Delete server error:', err.message); - res.status(500).json({ error: 'Failed to delete server: ' + err.message }); + res.status(500).json({ error: 'Failed to delete server' }); } }); From 07974d401dd9371ab41cd9daf6f12f177196fd95 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:37:19 +0200 Subject: [PATCH 12/51] fix(medium): add audit logging to admin CLI operations for accountability --- scripts/admin-cli.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/admin-cli.js b/scripts/admin-cli.js index 6ee4b0f..0e76e94 100644 --- a/scripts/admin-cli.js +++ b/scripts/admin-cli.js @@ -39,6 +39,17 @@ try { process.exit(1); } +async function auditLog(action, details) { + try { + await conn.query( + 'INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)', + [0, action, '[CLI] ' + details] + ); + } catch (err) { + console.error('Failed to audit log:', err.message); + } +} + async function usage() { console.log(` Usage: @@ -89,6 +100,7 @@ async function createAdmin(email, username, password) { [email, username, hash, username, 'Admin'] ); + await auditLog('admin_cli_create', `Created admin account: ${username} (${email}) - ID: ${result.insertId}`); console.log(`Admin account created: ${username} (${email}) - ID: ${result.insertId}`); } @@ -105,6 +117,7 @@ async function setAdmin(email) { process.exit(1); } + await auditLog('admin_cli_set_admin', `Granted admin privileges to ${email}`); console.log(`Admin privileges granted to ${email}`); } @@ -121,6 +134,7 @@ async function setAdminByUsername(username) { process.exit(1); } + await auditLog('admin_cli_set_admin', `Granted admin privileges to ${username} (by username)`); console.log(`Admin privileges granted to ${username}`); } @@ -150,6 +164,7 @@ async function removeAdmin(email) { process.exit(1); } + await auditLog('admin_cli_remove_admin', `Removed admin privileges from ${email}`); console.log(`Admin privileges removed from ${email}`); } From fe1d247fa83fa5cae0f386827483b08c65243d3d Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:37:44 +0200 Subject: [PATCH 13/51] fix(critical): restrict CORS origins in development mode to prevent cross-origin credential theft --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index 601ab2b..bf13bd0 100644 --- a/server.js +++ b/server.js @@ -116,7 +116,7 @@ app.use(helmet({ app.use(cors({ origin: process.env.NODE_ENV === 'production' ? ['https://dashboard.zero-host.org', 'https://zero-host.org'] - : '*', + : ['http://localhost:3000', 'http://127.0.0.1:3000'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], allowedHeaders: ['Content-Type', 'Authorization'], From aa592cafa9a315590c4a2e7fab3c00acca448706 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:38:18 +0200 Subject: [PATCH 14/51] fix(critical): prevent SQL injection in requireOwnership middleware by enforcing table/column whitelist --- middleware/auth.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/middleware/auth.js b/middleware/auth.js index 3f7faa9..309c511 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -75,9 +75,17 @@ export function requireNotRestricted(req, res, next) { next(); } +const ALLOWED_TABLES = new Set(['server_meta']); +const ALLOWED_COLUMNS = new Set(['ptero_server_id', 'user_id', 'id']); + export function requireOwnership(table, column, paramName, idSource = 'params') { return async (req, res, next) => { try { + if (!ALLOWED_TABLES.has(table) || !ALLOWED_COLUMNS.has(column)) { + console.error(`Blocked ownership check on unauthorized table/column: ${table}.${column}`); + return res.status(500).json({ error: 'Ownership verification failed' }); + } + const id = parseInt(idSource === 'params' ? req.params[paramName] : req.body[paramName], 10); if (isNaN(id)) { return res.status(400).json({ error: 'Invalid ID' }); From fd805efa6dc48861f8c96a59fd938301754fa022 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:44:56 +0200 Subject: [PATCH 15/51] fix: add missing PANEL_DB_NAME export from services/pyrodactyl.js for admin.js import --- services/pyrodactyl.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/pyrodactyl.js b/services/pyrodactyl.js index c25b9a5..c20477b 100644 --- a/services/pyrodactyl.js +++ b/services/pyrodactyl.js @@ -1,4 +1,6 @@ -import { PTERO_URL, PTERO_API_KEY, SERVER_LIMITS, FEATURE_LIMITS, DEPLOY_LOCATIONS } from '../config/pyrodactyl.js'; +import { PTERO_URL, PTERO_API_KEY, SERVER_LIMITS, FEATURE_LIMITS, DEPLOY_LOCATIONS, PANEL_DB_NAME } from '../config/pyrodactyl.js'; + +export { PANEL_DB_NAME }; const FETCH_TIMEOUT = 15000; const CACHE_TTL = 5 * 60 * 1000; From 6d22f87a44b4ba8b841f1beb063cf3433dd7df2b Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:45:42 +0200 Subject: [PATCH 16/51] fix: import randomBytes from crypto instead of using crypto.randomBytes (not a function) --- routes/passkeys.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/passkeys.js b/routes/passkeys.js index c4feac7..712f24a 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -8,7 +8,7 @@ import { } from '@simplewebauthn/server'; import { isoBase64URL } from '@simplewebauthn/server/helpers'; import { authenticateToken } from '../middleware/auth.js'; -import { createHash } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import { query } from '../config/db.js'; import { generateToken } from '../middleware/auth.js'; import { logActivity } from '../services/activity.js'; @@ -54,7 +54,7 @@ function getWebAuthnConfig(req) { const challengeMap = new Map(); function generateSessionToken() { - return crypto.randomBytes(32).toString('hex'); + return randomBytes(32).toString('hex'); } function getClientIp(req) { From 4822f5a3db366d7a645790489a15ca7b38dfda78 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:46:34 +0200 Subject: [PATCH 17/51] fix: add wasm-unsafe-eval to CSP for cap-widget WebAssembly, handle passkey autofill without sessionToken --- routes/passkeys.js | 32 ++++++++++++++++++++++++-------- server.js | 2 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/routes/passkeys.js b/routes/passkeys.js index 712f24a..2a9077a 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -225,17 +225,33 @@ router.post('/passkeys/login/complete', passkeyLoginLimiter, async (req, res) => if (!response) { return res.status(400).json({ error: 'Response is required' }); } - if (!sessionToken) { - return res.status(400).json({ error: 'Session token is required' }); - } - const stored = challengeMap.get(`login:${sessionToken}`); - if (!stored) { - return res.status(400).json({ error: 'No login in progress. Please try again.' }); + let stored; + if (sessionToken) { + stored = challengeMap.get(`login:${sessionToken}`); + if (!stored) { + return res.status(400).json({ error: 'No login in progress. Please try again.' }); + } + challengeMap.delete(`login:${sessionToken}`); + } else { + // Autofill / conditional mediation flow: look up challenge from clientDataJSON + try { + const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge; + for (const [key, entry] of challengeMap) { + if (key.startsWith('login:') && entry.challenge === challengeFromResponse) { + stored = entry; + challengeMap.delete(key); + break; + } + } + } catch { + return res.status(400).json({ error: 'No login in progress. Please try again.' }); + } + if (!stored) { + return res.status(400).json({ error: 'No login in progress. Please try again.' }); + } } - challengeMap.delete(`login:${sessionToken}`); - const passkeys = await query( 'SELECT id, credential_id, public_key, counter, transports, user_id FROM passkeys WHERE credential_id = ?', [response.id] diff --git a/server.js b/server.js index bf13bd0..e285cbd 100644 --- a/server.js +++ b/server.js @@ -90,7 +90,7 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], scriptSrcAttr: ["'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"], fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdn.jsdelivr.net"], From 868d1e5fc1978a009ae2fafc8fd2cbf59e0a6bb1 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:48:56 +0200 Subject: [PATCH 18/51] fix: suppress 4xx error logging from Pterodactyl API (callers handle gracefully), fix rename endpoint path --- services/pyrodactyl.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/pyrodactyl.js b/services/pyrodactyl.js index c20477b..3f10ff5 100644 --- a/services/pyrodactyl.js +++ b/services/pyrodactyl.js @@ -76,7 +76,9 @@ async function pteroFetch(path, options = {}) { continue; } if (!res.ok) { - recordPteroError(`${res.status} ${res.statusText}`); + if (res.status >= 500) { + recordPteroError(`${res.status} ${res.statusText}`); + } const text = await res.text(); throw new Error(`Pterodactyl API error ${res.status}: ${text.slice(0, 200)}`); } @@ -335,7 +337,7 @@ export async function getPergoServerIdsByEgg(nestId, eggId) { export async function renamePteroServer(serverId, name) { const server = await getServerById(serverId); - await pteroFetch(`/servers/${serverId}/details`, { + await pteroFetch(`/servers/${serverId}`, { method: 'PATCH', body: JSON.stringify({ name, From 547b12f23024ff2ced70d690db4f35b9961e57b1 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:55:43 +0200 Subject: [PATCH 19/51] fix: init Lucide icons on login page so passkey fingerprint SVG appears immediately instead of requiring a click+dismiss cycle --- public/js/app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/js/app.js b/public/js/app.js index 6ae3401..59719b3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -551,6 +551,7 @@ function renderLoginPage() { }); setupPasskeyAutofill(); + initIcons(); } function renderRegisterPage() { From 08eef6ca395d99248394b20c07ab49c8568668ff Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 09:58:39 +0200 Subject: [PATCH 20/51] fix: inline fingerprint SVG directly instead of relying on Lucide dynamic replacement (resolves icon not showing on login page) --- public/js/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 59719b3..c373d6a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -532,7 +532,7 @@ function renderLoginPage() { or @@ -712,7 +712,7 @@ async function handlePasskeyLogin() { } } finally { btn.disabled = false; - btn.innerHTML = ' Sign in with Passkey'; + btn.innerHTML = ' Sign in with Passkey'; initIcons(); } } From 020bc32c5ad4ef7f05af25aa491843682801c8b8 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:03:33 +0200 Subject: [PATCH 21/51] fix: add initIcons() with fallback timeout in renderLoginPage, bump cache version to force refresh --- public/index.html | 4 ++-- public/js/app.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/public/index.html b/public/index.html index f208569..8592a3d 100644 --- a/public/index.html +++ b/public/index.html @@ -17,7 +17,7 @@
- - + + diff --git a/public/js/app.js b/public/js/app.js index c373d6a..951d377 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -532,7 +532,7 @@ function renderLoginPage() { or @@ -552,6 +552,7 @@ function renderLoginPage() { setupPasskeyAutofill(); initIcons(); + setTimeout(initIcons, 100); } function renderRegisterPage() { @@ -712,7 +713,7 @@ async function handlePasskeyLogin() { } } finally { btn.disabled = false; - btn.innerHTML = ' Sign in with Passkey'; + btn.innerHTML = ' Sign in with Passkey'; initIcons(); } } From 37a313389e5d4fd9ac9789522cc8fce5396897b5 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:26:53 +0200 Subject: [PATCH 22/51] fix: add 'unsafe-eval' to CSP script-src for cap-widget instrumentation --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index e285cbd..15d867b 100644 --- a/server.js +++ b/server.js @@ -90,7 +90,7 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "'wasm-unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], scriptSrcAttr: ["'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"], fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdn.jsdelivr.net"], From cc13e717a0a1ff20cbd6d6d48c3f46a8f132899f Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:29:14 +0200 Subject: [PATCH 23/51] switch GH Actions from password to SSH key auth --- .github/workflows/deploy-beta.yml | 3 ++- .github/workflows/deploy-main.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 9048598..fa7402b 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -13,8 +13,9 @@ jobs: with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} - password: ${{ secrets.SSH_PASSWORD }} + key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT || 22 }} + host_key_checking: false script: | cd ${{ secrets.BETA_PATH }} cp .env /tmp/beta.env 2>/dev/null; cp port.txt /tmp/beta.port.txt 2>/dev/null diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml index 6eeb022..4b4461d 100644 --- a/.github/workflows/deploy-main.yml +++ b/.github/workflows/deploy-main.yml @@ -13,8 +13,9 @@ jobs: with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} - password: ${{ secrets.SSH_PASSWORD }} + key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT || 22 }} + host_key_checking: false script: | cd ${{ secrets.MAIN_PATH }} cp .env /tmp/main.env 2>/dev/null; cp port.txt /tmp/main.port.txt 2>/dev/null From dd4753d70c8d1c0e3c6b4e6e2297dcf11accb3a4 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:31:56 +0200 Subject: [PATCH 24/51] chore: trigger deploy From 1135a12a9e5964885712c9ac928faa8464930cc8 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:45:45 +0200 Subject: [PATCH 25/51] feat: complete mobile responsive redesign - 3 breakpoints: 1024px, 768px, 480px - Tables transform to card layout on mobile with data-label attributes - Off-canvas sidebar with backdrop overlay on mobile - Admin navbar scrollable on mobile - Bottom-sheet modals, full-width toasts, stacked cookie banner - Responsive wizard, auth pages, resource gauges, activity timeline - Close sidebar on navigate for mobile UX --- public/css/style.css | 996 +++++++++++++++++++++++++++++++++++++++++-- public/js/admin.js | 106 ++--- public/js/app.js | 19 +- 3 files changed, 1024 insertions(+), 97 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 6532807..f7178ed 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1581,9 +1581,49 @@ tbody tr:hover { padding: 8px; } +/* ─── Tablet & below (≤ 1024px) ─── */ +@media (max-width: 1024px) { + .main-content { + padding: 24px; + } + + .admin-content { + padding: 24px; + } + + .page-title { + font-size: 1.5rem; + } + + .stat-value { + font-size: 1.7rem; + } + + .modal { + max-width: 480px; + } + + .wizard-progress { + max-width: 100%; + } + + .wizard-step-label { + font-size: 0.75rem; + } + + .wizard-step-indicator { + padding: 6px 8px; + gap: 6px; + } +} + +/* ─── Mobile (≤ 768px) ─── */ @media (max-width: 768px) { + /* Sidebar: off-canvas */ .sidebar { transform: translateX(-100%); + width: 260px; + z-index: 200; } .sidebar.open { @@ -1594,44 +1634,953 @@ tbody tr:hover { display: none; } + .sidebar.collapsed { + width: 260px; + } + + .sidebar.collapsed .sidebar-resizer { + display: none; + } + + .sidebar.collapsed ~ .main-content { + margin-left: 0; + } + + /* Sidebar backdrop for mobile */ + .sidebar-backdrop { + display: none; + } + + .sidebar.open ~ .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); + } + + /* Main content: full width */ .main-content { margin-left: 0; - padding: 20px; + padding: 16px; + padding-top: 60px; } + /* Hamburger button */ .hamburger-toggle { display: flex; align-items: center; justify-content: center; position: fixed; - top: 16px; - left: 16px; + top: 12px; + left: 12px; z-index: 101; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + } + + /* Page header */ + .page-header { + margin-bottom: 24px; + } + + .page-title { + font-size: 1.35rem; + word-break: break-word; } + .page-subtitle { + font-size: 0.85rem; + } + + /* Stat grid: 2 columns on tablet, 1 on mobile */ .stat-grid { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .stat-card { + padding: 16px; + } + + .stat-value { + font-size: 1.5rem; + } + + .stat-label { + font-size: 0.75rem; } + /* Server grid: single column */ .server-grid { grid-template-columns: 1fr; + gap: 12px; + } + + .server-card { + padding: 16px; + } + + .server-card-name { + font-size: 0.92rem; + } + + /* Cards */ + .card { + padding: 16px; + border-radius: var(--radius-md); + } + + .card-header { + flex-wrap: wrap; + gap: 8px; + } + + /* Tables: card layout on mobile */ + .table-container { + background: transparent; + border: none; + border-radius: 0; + overflow: visible; } + .table-container table { + width: 100%; + } + + .table-container thead { + display: none; + } + + .table-container tbody { + display: flex; + flex-direction: column; + gap: 10px; + } + + .table-container tbody tr { + display: flex; + flex-direction: column; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 14px 16px; + gap: 6px; + } + + .table-container tbody tr:hover { + background: var(--bg-card); + } + + .table-container tbody td { + padding: 0; + border-bottom: none; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .table-container tbody td::before { + content: attr(data-label); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; + } + + .table-container tbody td:last-child { + border-top: 1px solid rgba(255, 255, 255, 0.04); + padding-top: 8px; + margin-top: 2px; + justify-content: flex-end; + } + + .table-container tbody td:first-child::before { + display: none; + } + + .table-container tbody td:first-child { + font-weight: 700; + font-size: 0.92rem; + } + + /* Form rows */ .form-row { flex-direction: column; gap: 0; } + /* Auth card */ .auth-card { - padding: 32px 24px; + padding: 28px 20px; + border-radius: var(--radius-lg); } - .table-container { + .auth-title { + font-size: 1.35rem; + } + + .auth-logo-text { + font-size: 1.2rem; + } + + /* Tabs: scrollable */ + .tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + gap: 0; + padding-bottom: 0; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab { + padding: 10px 14px; + font-size: 0.82rem; + white-space: nowrap; + flex-shrink: 0; + } + + /* Server detail */ + .server-detail-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + .detail-item { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .detail-label { + font-size: 0.72rem; + } + + .detail-value { + font-size: 0.85rem; + word-break: break-all; + } + + /* Resource gauges */ + .resource-gauges { + grid-template-columns: 1fr; + gap: 12px; + } + + .resource-gauge { + padding: 16px 12px; + } + + .resource-gauge-value { + font-size: 1.4rem; + } + + /* Servers toolbar */ + .servers-toolbar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .search-wrapper { + min-width: 0; + } + + .filter-group { + justify-content: center; + flex-wrap: wrap; + } + + .filter-btn { + padding: 6px 12px; + font-size: 0.75rem; + } + + /* Modal */ + .modal-overlay { + padding: 16px; + align-items: flex-end; + } + + .modal { + max-width: 100%; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + padding: 24px 20px; + max-height: 85vh; + } + + .modal-title { + font-size: 1.15rem; + margin-bottom: 20px; + } + + .modal-actions { + flex-direction: column; + gap: 8px; + margin-top: 20px; + } + + .modal-actions .btn { + width: 100%; + } + + /* Toast */ + .toast-container { + bottom: 16px; + right: 16px; + left: 16px; + z-index: 500; + } + + .toast { + padding: 12px 16px; + font-size: 0.82rem; + } + + /* Notification panel: full width on mobile */ + .notif-panel { + left: 0; + width: 100%; + } + + .sidebar.collapsed ~ .notif-panel { + left: 0; + } + + /* Cookie banner */ + .cookie-banner { + flex-direction: column; + padding: 16px; + gap: 12px; + align-items: stretch; + } + + .cookie-banner-text { + min-width: 0; + } + + .cookie-banner-text p { + font-size: 0.82rem; + } + + .cookie-banner-actions { + justify-content: stretch; + flex-wrap: nowrap; + } + + .cookie-banner-actions .btn { + flex: 1; + } + + /* Empty state */ + .empty-state { + padding: 40px 16px; + } + + .empty-state-icon { + width: 48px; + height: 48px; + } + + .empty-state-title { + font-size: 1rem; + } + + .empty-state-desc { + font-size: 0.82rem; + } + + /* Wizard */ + .wizard-progress { + padding: 6px; + border-radius: var(--radius-md); + } + + .wizard-step-indicator { + padding: 6px; + gap: 4px; + } + + .wizard-step-circle { + width: 24px; + height: 24px; + } + + .wizard-step-circle svg { + width: 12px !important; + height: 12px !important; + } + + .wizard-step-label { + font-size: 0.65rem; + } + + .wizard-step-title { + font-size: 1.1rem; + } + + .wizard-step-desc { + font-size: 0.82rem; + } + + .wizard-actions { + flex-direction: column-reverse; + gap: 8px; + } + + .wizard-actions .btn { + width: 100%; + } + + #wizard-next-btn { + margin-left: 0; + } + + /* Nest grid */ + .nest-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .nest-card { + padding: 16px 12px; + } + + .nest-card-logo { + width: 48px; + height: 48px; + } + + .nest-card-name { + font-size: 0.85rem; + } + + .nest-card-desc { + font-size: 0.72rem; + } + + /* Egg grid */ + .egg-grid { + grid-template-columns: 1fr; + gap: 8px; + } + + .egg-card { + padding: 12px; + gap: 10px; + } + + .egg-card-logo { + width: 32px; + height: 32px; + } + + .egg-card-name { + font-size: 0.85rem; + } + + .egg-card-desc { + font-size: 0.75rem; + } + + /* Summary card */ + .summary-card { + padding: 16px; + max-width: 100%; + } + + .summary-row { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .summary-value { + text-align: left; + max-width: 100%; + } + + /* Account page */ + .account-grid { + max-width: 100%; + } + + .account-menu-icon { + width: 36px; + height: 36px; + } + + /* Pyrodactyl page */ + .ptero-grid { + max-width: 100%; + } + + .ptero-card-icon { + width: 44px; + height: 44px; + margin-bottom: 14px; + } + + .ptero-card-title { + font-size: 1.15rem; + } + + /* Activity timeline */ + .activity-item { + gap: 10px; + padding: 10px 0; + } + + .activity-icon { + width: 28px; + height: 28px; + font-size: 0.7rem; + } + + .activity-action { + font-size: 0.82rem; + } + + .activity-details { + font-size: 0.72rem; + } + + .activity-time { + font-size: 0.68rem; + } + + /* Log pagination */ + .log-pagination { + flex-wrap: wrap; + gap: 8px; + } + + .log-pagination-info { + font-size: 0.75rem; + width: 100%; + text-align: center; + } + + /* API key input group */ + .api-key-input-group { + flex-direction: column; + gap: 8px; + } + + /* Buttons: ensure touch targets */ + .btn { + min-height: 40px; + } + + .btn-sm { + min-height: 34px; + padding: 6px 12px; + font-size: 0.78rem; + } + + /* SVG picker */ + .svg-picker-grid { + grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); + } + + /* Admin navbar */ + .admin-navbar { + padding: 0 12px; + gap: 8px; + height: 52px; + } + + .admin-navbar-brand { + font-size: 0.82rem; + } + + .admin-navbar-logo { + width: 24px; + height: 24px; + } + + .admin-badge { + font-size: 0.55rem; + padding: 1px 5px; + } + + .admin-navbar-center { + gap: 2px; overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding: 0 4px; + } + + .admin-navbar-center::-webkit-scrollbar { + display: none; + } + + .admin-nav-link { + padding: 6px 10px; + font-size: 0.78rem; + white-space: nowrap; + flex-shrink: 0; + } + + .admin-nav-link svg { + width: 14px; + height: 14px; + } + + .admin-navbar-user { + display: none; + } + + .admin-content { + padding: 16px; + } + + .admin-notice { + font-size: 0.78rem; + padding: 10px 12px; + } + + /* Toggle switch */ + .toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + } + + /* Cap modal */ + .cap-modal { + padding: 16px; + margin: 16px; + } + + .cap-modal cap-widget { + width: 100% !important; + } + + /* Notif view modal */ + .notif-view-modal-content { + width: 95%; + max-height: 75vh; + } + + .notif-view-modal-header { + padding: 16px 16px 0; + } + + .notif-view-modal-body { + padding: 12px 16px 16px; + font-size: 0.85rem; + } + + /* Server detail page header */ + .server-detail-grid .card-title { + font-size: 0.92rem; + } + + /* Action cards */ + .action-card { + padding: 16px; + border-radius: var(--radius-md); + margin-bottom: 12px; + } + + .action-card-header { + gap: 10px; + margin-bottom: 12px; + } + + .action-card-header svg { + width: 20px; + height: 20px; + } + + .action-card-title { + font-size: 0.92rem; + } + + .action-card-desc { + font-size: 0.8rem; + } + + /* Server card actions */ + .server-card-actions { + flex-wrap: wrap; + } + + .server-card-actions .btn { + flex: 1; + min-width: 0; + } + + /* Power controls grid */ + [style*="display:flex"][style*="gap:8px"][style*="flex-wrap:wrap"] { + gap: 6px !important; + } +} + +/* ─── Small mobile (≤ 480px) ─── */ +@media (max-width: 480px) { + .main-content { + padding: 12px; + padding-top: 56px; + } + + .hamburger-toggle { + top: 10px; + left: 10px; + padding: 8px; + } + + .stat-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .stat-card { + padding: 14px; + display: flex; + align-items: center; + gap: 14px; + } + + .stat-icon { + margin-bottom: 0; + } + + .stat-card::before { + height: 100%; + } + + .page-title { + font-size: 1.2rem; + } + + .auth-card { + padding: 24px 16px; + } + + .auth-title { + font-size: 1.2rem; + } + + .auth-subtitle { + font-size: 0.82rem; + margin-bottom: 24px; + } + + .form-group input, + .form-group select, + .form-group textarea { + padding: 10px 12px; + font-size: 0.88rem; + } + + .btn { + min-height: 44px; + font-size: 0.85rem; + } + + .btn-sm { + min-height: 36px; + font-size: 0.75rem; + } + + .server-card-name { + font-size: 0.88rem; + } + + .server-card-status { + font-size: 0.65rem; + padding: 2px 8px; + } + + .server-detail-tag { + font-size: 0.68rem; + padding: 3px 7px; + } + + .nest-grid { + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .nest-card { + padding: 12px 8px; + } + + .nest-card-logo { + width: 40px; + height: 40px; + } + + .nest-card-name { + font-size: 0.78rem; + } + + .wizard-step-indicator { + padding: 4px; + gap: 2px; + } + + .wizard-step-circle { + width: 20px; + height: 20px; + } + + .wizard-step-label { + font-size: 0.6rem; + } + + .wizard-step-title { + font-size: 1rem; + } + + .detail-item { + padding-bottom: 6px; + } + + .resource-gauge-value { + font-size: 1.2rem; + } + + .resource-gauge-label { + font-size: 0.68rem; + } + + .resource-gauge-sub { + font-size: 0.6rem; + } + + .modal { + padding: 20px 16px; + } + + .modal-title { + font-size: 1.05rem; + } + + .admin-navbar { + height: 48px; + padding: 0 8px; + gap: 6px; + } + + .admin-navbar-brand { + font-size: 0.75rem; + gap: 4px; + } + + .admin-nav-link { + padding: 5px 8px; + font-size: 0.72rem; + } + + .admin-content { + padding: 12px; + } + + .page-header .btn-ghost[style*="margin-bottom:16px"] { + margin-bottom: 12px !important; + } + + /* Summary card on very small screens */ + .summary-row { + gap: 4px; + padding: 8px 0; + } + + .summary-label { + font-size: 0.78rem; + } + + .summary-value { + font-size: 0.85rem; + } + + .toast-container { + bottom: 12px; + right: 12px; + left: 12px; + } + + .toast { + padding: 10px 14px; + font-size: 0.78rem; + } + + .cookie-banner { + padding: 12px; + } + + .cookie-banner-actions .btn { + padding: 8px 12px; + font-size: 0.78rem; + } + + /* Notif panel */ + .notif-panel-header { + padding: 14px 14px 12px; + } + + .notif-panel-header h3 { + font-size: 0.95rem; + } + + .notif-item { + padding: 10px 14px; + } + + .notif-item-title { + font-size: 0.82rem; + } + + .notif-item-msg { + font-size: 0.75rem; + } + + /* Activity */ + .activity-item { + gap: 8px; + padding: 8px 0; + } + + .activity-icon { + width: 24px; + height: 24px; + font-size: 0.6rem; + } + + .activity-action { + font-size: 0.78rem; + } + + .activity-time { + font-size: 0.65rem; + } + + /* Admin table card layout adjustments */ + .admin-content .table-container tbody td:first-child { + font-size: 0.88rem; + } + + .admin-content .table-container tbody td { + font-size: 0.8rem; } } @@ -1811,11 +2760,7 @@ tbody tr:hover { font-weight: 500; } -@media (max-width: 768px) { - .server-detail-grid { - grid-template-columns: 1fr; - } -} +/* server-detail-grid mobile: handled in RESPONSIVE section */ /* ===== RGPD / COOKIE CONSENT ===== */ .cookie-banner { @@ -2237,18 +3182,7 @@ tbody tr:hover { 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; - } -} +/* servers-toolbar + resource-gauges mobile: handled in RESPONSIVE section */ /* ===== ADMIN PANEL ===== */ .admin-layout { @@ -2402,21 +3336,7 @@ tbody tr:hover { 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; - } -} +/* admin mobile: handled in RESPONSIVE section */ /* ─── Create Wizard ─── */ .wizard-progress { diff --git a/public/js/admin.js b/public/js/admin.js index e7c6dca..1086c56 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -563,12 +563,12 @@ async function renderAdminServers() { return ahtml` - ${escapeHtml(s.name)} - ${escapeHtml(ownerName)} - ${escapeHtml(eggName)} - ${escapeHtml(allocStr)} - ${escapeHtml(statusLabel)} - + ${escapeHtml(s.name)} + ${escapeHtml(ownerName)} + ${escapeHtml(eggName)} + ${escapeHtml(allocStr)} + ${escapeHtml(statusLabel)} + Details @@ -1181,13 +1181,13 @@ async function renderAdminUsers() { tbody.innerHTML = data.users.map(u => ahtml` - ${u.id} - ${escapeHtml(u.username)} - ${escapeHtml(u.email)} - ${u.is_admin ? 'Admin' : 'User'} - ${u.server_count} - ${formatDateWithTooltip(u.created_at)} - + ${u.id} + ${escapeHtml(u.username)} + ${escapeHtml(u.email)} + ${u.is_admin ? 'Admin' : 'User'} + ${u.server_count} + ${formatDateWithTooltip(u.created_at)} + Details @@ -1278,11 +1278,11 @@ async function renderAdminUserDetail(userId) { ${data.servers.map(s => ahtml` - ${s.server_name || 'Unknown'} - ${s.status} - ${formatDateWithTooltip(s.created_at)} - ${formatDateWithTooltip(s.expires_at)} - + ${s.server_name || 'Unknown'} + ${s.status} + ${formatDateWithTooltip(s.created_at)} + ${formatDateWithTooltip(s.expires_at)} + Manage @@ -1516,10 +1516,10 @@ async function renderAdminActivity() { tbody.innerHTML = data.activities.map(a => ahtml` - ${formatDateWithTooltip(a.created_at)} - ${a.username || 'Unknown'} - ${a.action} - ${a.details || ''} + ${formatDateWithTooltip(a.created_at)} + ${a.username || 'Unknown'} + ${a.action} + ${a.details || ''} `).join(''); } catch (err) { @@ -1613,19 +1613,19 @@ async function renderAdminEggsSettings() { tbody.innerHTML = data.nests.map(n => ahtml` - ${n.id} - ${n.logo ? renderLogoDisplay(n.logo, 32) : ''} - ${n.name} - ${n.description || '—'} - ${n.ptero_nest_id} - + ${n.id} + ${n.logo ? renderLogoDisplay(n.logo, 32) : ''} + ${n.name} + ${n.description || '—'} + ${n.ptero_nest_id} + ${n.unavailable ? 'Unavailable' : 'Available'} - + @@ -1723,19 +1723,19 @@ async function renderAdminNestEggs(nestId) { const isUnavailable = res?.unavailable; return ahtml` - ${e.id} - ${res?.logo ? renderLogoDisplay(res.logo, 28) : ''} - ${e.name} - ${e.description || '—'} - ${resStr} - + ${e.id} + ${res?.logo ? renderLogoDisplay(res.logo, 28) : ''} + ${e.name} + ${e.description || '—'} + ${resStr} + ${isUnavailable ? 'Unavailable' : 'Available'} - + Configure @@ -2395,14 +2395,14 @@ async function renderAdminNodes() { const isOnline = n.is_online; return ahtml` - ${n.id} - ${escapeHtml(n.name)} - ${escapeHtml(n.fqdn)} - ${ramMB !== '—' ? ramMB + ' MB' : '—'} - ${diskGB !== '—' ? diskGB + ' GB' : '—'} - ${cpuPct} - ${allocCount} - ${isOnline !== undefined ? (isOnline ? 'Online' : 'Offline') : '—'} + ${n.id} + ${escapeHtml(n.name)} + ${escapeHtml(n.fqdn)} + ${ramMB !== '—' ? ramMB + ' MB' : '—'} + ${diskGB !== '—' ? diskGB + ' GB' : '—'} + ${cpuPct} + ${allocCount} + ${isOnline !== undefined ? (isOnline ? 'Online' : 'Offline') : '—'} `; }).join(''); @@ -2572,11 +2572,11 @@ async function renderAdminNodeDetail(nodeId) { } else { allocTbody.innerHTML = allocs.map(a => ahtml` - ${a.id} - ${escapeHtml(a.ip)} - ${a.port} - ${a.alias ? escapeHtml(a.alias) : '—'} - ${a.server ? `${a.server}` : 'Free'} + ${a.id} + ${escapeHtml(a.ip)} + ${a.port} + ${a.alias ? escapeHtml(a.alias) : '—'} + ${a.server ? `${a.server}` : 'Free'} `).join(''); } @@ -2590,10 +2590,10 @@ async function renderAdminNodeDetail(nodeId) { } else { serversTbody.innerHTML = servers.map(s => ahtml` - ${s.id} - ${escapeHtml(s.name)} - ${escapeHtml(s.identifier)} - ${s.status || '—'} + ${s.id} + ${escapeHtml(s.name)} + ${escapeHtml(s.identifier)} + ${s.status || '—'} `).join(''); } diff --git a/public/js/app.js b/public/js/app.js index 951d377..5818750 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1006,6 +1006,7 @@ async function renderDashboard() { +
@@ -1081,6 +1082,10 @@ async function renderDashboard() { $('#sidebar').classList.toggle('open'); }); + $('#sidebar-backdrop').addEventListener('click', () => { + $('#sidebar').classList.remove('open'); + }); + initSidebarResize(); initSidebarTooltip(); @@ -1267,6 +1272,8 @@ document.addEventListener('click', (e) => { function navigateTo(page) { if (state.notifPanelOpen) closeNotifPanel(); + const sidebar = $('#sidebar'); + if (sidebar) sidebar.classList.remove('open'); if (passkeyAbortController) { passkeyAbortController.abort(); passkeyAbortController = null; @@ -1755,14 +1762,14 @@ function renderServerRow(s) { const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); return html` - ${escapeHtml(s.name)} - ${eggName} - ${allocStr} - + ${escapeHtml(s.name)} + ${eggName} + ${allocStr} + ${statusLabel} - -
+ +
Settings ${canRenew && !isAdminSuspended ? html` From 8277b5b05dc65caf23844cf82794f0303a21f973 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:52:12 +0200 Subject: [PATCH 26/51] fix: improve /create wizard and notification panel mobile layout - Wizard progress bar: scrollable, proper sizing at all breakpoints - Nest/egg grids: better touch targets, line-clamp on descriptions - Summary card + cap-widget: full width on mobile - Create page header: stacked layout on mobile - Notification panel: full-screen slide-in, proper z-index, better item spacing - Notification items: proper icon/text sizing at 768px and 480px - 480px: tighter wizard steps, smaller nest/egg cards, notif fixes --- public/css/style.css | 243 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 232 insertions(+), 11 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index f7178ed..19204f7 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1955,12 +1955,69 @@ tbody tr:hover { .notif-panel { left: 0; width: 100%; + top: 0; + bottom: 0; + height: 100%; + border-right: none; + border-radius: 0; + z-index: 150; } .sidebar.collapsed ~ .notif-panel { left: 0; } + .notif-panel-header { + padding: 16px 16px 14px; + border-bottom: 1px solid var(--border); + } + + .notif-panel-header h3 { + font-size: 1rem; + } + + .notif-mark-all { + font-size: 0.78rem; + padding: 6px 10px; + } + + .notif-item { + padding: 12px 16px; + gap: 10px; + } + + .notif-item-icon { + width: 32px; + height: 32px; + } + + .notif-icon { + width: 16px; + height: 16px; + } + + .notif-item-title { + font-size: 0.85rem; + } + + .notif-item-msg { + font-size: 0.78rem; + line-height: 1.4; + } + + .notif-item-time { + font-size: 0.7rem; + } + + .notif-empty { + padding: 40px 16px; + font-size: 0.85rem; + } + + .notif-backdrop { + z-index: 149; + } + /* Cookie banner */ .cookie-banner { flex-direction: column; @@ -2008,25 +2065,32 @@ tbody tr:hover { .wizard-progress { padding: 6px; border-radius: var(--radius-md); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .wizard-progress::-webkit-scrollbar { + display: none; } .wizard-step-indicator { - padding: 6px; - gap: 4px; + padding: 6px 8px; + gap: 5px; } .wizard-step-circle { - width: 24px; - height: 24px; + width: 26px; + height: 26px; } .wizard-step-circle svg { - width: 12px !important; - height: 12px !important; + width: 13px !important; + height: 13px !important; } .wizard-step-label { - font-size: 0.65rem; + font-size: 0.7rem; } .wizard-step-title { @@ -2035,6 +2099,7 @@ tbody tr:hover { .wizard-step-desc { font-size: 0.82rem; + margin-bottom: 16px; } .wizard-actions { @@ -2050,6 +2115,13 @@ tbody tr:hover { margin-left: 0; } + /* Create page header fix */ + #page-create .page-header { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + /* Nest grid */ .nest-grid { grid-template-columns: repeat(2, 1fr); @@ -2058,6 +2130,7 @@ tbody tr:hover { .nest-card { padding: 16px 12px; + border-radius: var(--radius-md); } .nest-card-logo { @@ -2065,12 +2138,24 @@ tbody tr:hover { height: 48px; } + .nest-card-logo img, + .nest-card-logo i { + width: 32px !important; + height: 32px !important; + } + .nest-card-name { font-size: 0.85rem; + line-height: 1.2; } .nest-card-desc { font-size: 0.72rem; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } /* Egg grid */ @@ -2082,6 +2167,18 @@ tbody tr:hover { .egg-card { padding: 12px; gap: 10px; + border-radius: var(--radius-md); + } + + .egg-card-logo { + width: 36px; + height: 36px; + } + + .egg-card-logo img, + .egg-card-logo i { + width: 28px !important; + height: 28px !important; } .egg-card-logo { @@ -2434,23 +2531,80 @@ tbody tr:hover { } .wizard-step-indicator { - padding: 4px; + padding: 4px 6px; gap: 2px; } .wizard-step-circle { - width: 20px; - height: 20px; + width: 22px; + height: 22px; + } + + .wizard-step-circle svg { + width: 11px !important; + height: 11px !important; } .wizard-step-label { - font-size: 0.6rem; + font-size: 0.62rem; } .wizard-step-title { font-size: 1rem; } + .wizard-step-desc { + font-size: 0.78rem; + } + + .nest-grid { + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .nest-card { + padding: 12px 8px; + } + + .nest-card-logo { + width: 40px; + height: 40px; + } + + .nest-card-logo img, + .nest-card-logo i { + width: 28px !important; + height: 28px !important; + } + + .nest-card-name { + font-size: 0.78rem; + } + + .egg-card { + padding: 10px; + gap: 8px; + } + + .egg-card-logo { + width: 30px; + height: 30px; + } + + .egg-card-logo img, + .egg-card-logo i { + width: 24px !important; + height: 24px !important; + } + + .egg-card-name { + font-size: 0.82rem; + } + + .egg-card-desc { + font-size: 0.72rem; + } + .detail-item { padding-bottom: 6px; } @@ -2533,6 +2687,73 @@ tbody tr:hover { font-size: 0.78rem; } + /* Notif panel 480px */ + .notif-panel-header { + padding: 12px 12px 10px; + } + + .notif-panel-header h3 { + font-size: 0.92rem; + } + + .notif-mark-all { + font-size: 0.72rem; + padding: 4px 8px; + } + + .notif-item { + padding: 10px 12px; + gap: 8px; + } + + .notif-item-icon { + width: 28px; + height: 28px; + } + + .notif-icon { + width: 14px; + height: 14px; + } + + .notif-item-title { + font-size: 0.8rem; + } + + .notif-item-msg { + font-size: 0.72rem; + } + + .notif-item-time { + font-size: 0.65rem; + } + + .notif-dot { + width: 6px; + height: 6px; + margin-top: 4px; + } + + /* Create page 480px */ + .wizard-step-indicator { + padding: 3px 5px; + gap: 2px; + } + + .wizard-step-circle { + width: 20px; + height: 20px; + } + + .wizard-step-circle svg { + width: 10px !important; + height: 10px !important; + } + + .wizard-step-label { + font-size: 0.58rem; + } + /* Notif panel */ .notif-panel-header { padding: 14px 14px 12px; From fbea81883a4898c27c81420d451607e227575e94 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:54:13 +0200 Subject: [PATCH 27/51] fix: close sidebar on mobile when opening notification panel --- public/js/app.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/js/app.js b/public/js/app.js index 5818750..5f30aad 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -441,6 +441,10 @@ function openNotifPanel() { $('#notif-backdrop').classList.add('open'); document.body.style.overflow = 'hidden'; + if (window.innerWidth <= 768) { + $('#sidebar').classList.remove('open'); + } + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); $('#nav-notifications').classList.add('active'); updateNavIndicator(); From d29ec0ba3cbc095c176033c10ed1758b08ec1623 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:54:38 +0200 Subject: [PATCH 28/51] fix: hide wizard progress bar on mobile --- public/css/style.css | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 19204f7..ecba42e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2063,11 +2063,7 @@ tbody tr:hover { /* Wizard */ .wizard-progress { - padding: 6px; - border-radius: var(--radius-md); - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; + display: none; } .wizard-progress::-webkit-scrollbar { From da81f35a0831e15f6f286d041c982b988a905b7d Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:55:44 +0200 Subject: [PATCH 29/51] fix: notif panel full width on phones only, fixed on tablets --- public/css/style.css | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index ecba42e..99a2bbe 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1951,10 +1951,9 @@ tbody tr:hover { font-size: 0.82rem; } - /* Notification panel: full width on mobile */ + /* Notification panel: adapts on tablet */ .notif-panel { - left: 0; - width: 100%; + width: 340px; top: 0; bottom: 0; height: 100%; @@ -2683,7 +2682,12 @@ tbody tr:hover { font-size: 0.78rem; } - /* Notif panel 480px */ + /* Notif panel: full width on phones */ + .notif-panel { + left: 0; + width: 100%; + } + .notif-panel-header { padding: 12px 12px 10px; } From c10675329fd0f9e7fa6043af0fbe6d5ed4a2a2b2 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:56:39 +0200 Subject: [PATCH 30/51] fix: mobile notif panel shows X close button instead of Mark all read --- public/css/style.css | 31 +++++++++++++++++++++++++++++-- public/js/app.js | 8 +++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 99a2bbe..73e4d03 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1343,12 +1343,22 @@ tbody tr:hover { flex-shrink: 0; } +.notif-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + .notif-panel-header h3 { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); } +.notif-close-mobile { + display: none; +} + .notif-mark-all { background: none; border: none; @@ -1976,8 +1986,25 @@ tbody tr:hover { } .notif-mark-all { - font-size: 0.78rem; - padding: 6px 10px; + display: none; + } + + .notif-close-mobile { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition); + } + + .notif-close-mobile:hover { + color: var(--text-primary); + background: var(--bg-3); } .notif-item { diff --git a/public/js/app.js b/public/js/app.js index 5f30aad..4500bdc 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1015,7 +1015,12 @@ async function renderDashboard() {

Notifications

- +
+ + +
No notifications yet
@@ -1074,6 +1079,7 @@ async function renderDashboard() { }); $('#notif-backdrop').addEventListener('click', closeNotifPanel); + $('#notif-close-mobile').addEventListener('click', closeNotifPanel); $('#notif-mark-all').addEventListener('click', markAllAsRead); From fb9c2867aefd154a4b7608ee4714baf59e1b2355 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:57:45 +0200 Subject: [PATCH 31/51] fix: notif panel uses max-width to prevent overflow on all screens --- public/css/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/css/style.css b/public/css/style.css index 73e4d03..020de91 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1963,7 +1963,8 @@ tbody tr:hover { /* Notification panel: adapts on tablet */ .notif-panel { - width: 340px; + width: 100%; + max-width: 340px; top: 0; bottom: 0; height: 100%; From b27d761a9b27922926dad3e9763a643a47782cf1 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:58:30 +0200 Subject: [PATCH 32/51] fix: notif panel full width on mobile --- public/css/style.css | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 020de91..7343188 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1964,7 +1964,6 @@ tbody tr:hover { /* Notification panel: adapts on tablet */ .notif-panel { width: 100%; - max-width: 340px; top: 0; bottom: 0; height: 100%; @@ -2710,12 +2709,7 @@ tbody tr:hover { font-size: 0.78rem; } - /* Notif panel: full width on phones */ - .notif-panel { - left: 0; - width: 100%; - } - + /* Notif panel */ .notif-panel-header { padding: 12px 12px 10px; } From 5ba929e65322b6287e589f56375f5b6123d647e6 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 10:59:51 +0200 Subject: [PATCH 33/51] fix: reset notif panel left position on mobile --- public/css/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/public/css/style.css b/public/css/style.css index 7343188..9266fab 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1963,6 +1963,7 @@ tbody tr:hover { /* Notification panel: adapts on tablet */ .notif-panel { + left: 0; width: 100%; top: 0; bottom: 0; From cc576905721716a0a1a2ce65af94f2ce78454fa6 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 11:01:19 +0200 Subject: [PATCH 34/51] fix: hide wizard progress bar on mobile (override after base styles) --- public/css/style.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/public/css/style.css b/public/css/style.css index 9266fab..1f62cfe 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -3702,6 +3702,16 @@ tbody tr:hover { padding-top: 20px; } +.wizard-progress::-webkit-scrollbar { + display: none; +} + +@media (max-width: 768px) { + .wizard-progress { + display: none; + } +} + /* Nest Cards Grid */ .nest-grid { display: grid; From 006984ede4a8aec83e7543961488ee6104befee0 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 11:05:14 +0200 Subject: [PATCH 35/51] fix: hide create page header text on mobile --- public/css/style.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 1f62cfe..e6a004f 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2140,9 +2140,7 @@ tbody tr:hover { /* Create page header fix */ #page-create .page-header { - flex-direction: column; - align-items: flex-start; - gap: 4px; + display: none; } /* Nest grid */ From dcfed2cf8a37085dc705efb3dc7345d193b5021d Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 11:05:47 +0200 Subject: [PATCH 36/51] fix: force hide create page header on mobile (inline style override) --- public/css/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/css/style.css b/public/css/style.css index e6a004f..91fbc39 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2140,7 +2140,7 @@ tbody tr:hover { /* Create page header fix */ #page-create .page-header { - display: none; + display: none !important; } /* Nest grid */ From a3a84d047a3d0622095cfb2494a14053621e4350 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 10 Jul 2026 11:06:58 +0200 Subject: [PATCH 37/51] fix: disable sidebar collapse on logo click on mobile --- public/js/app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/js/app.js b/public/js/app.js index 4500bdc..177ba78 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1085,6 +1085,7 @@ async function renderDashboard() { $('#sidebar-logo-link').addEventListener('click', (e) => { e.preventDefault(); + if (window.innerWidth <= 768) return; toggleSidebarCollapse(); }); From 0266a40f02b5b977edc294e3289ad5bca26f1e20 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 11 Jul 2026 10:27:52 +0200 Subject: [PATCH 38/51] feat: add server dropdown menu in sidebar Replace static 'My Servers' link with a toggleable dropdown that lists user's servers with status dots, allowing direct navigation to /server/ID pages. Servers are lazy-loaded on first open. --- public/css/style.css | 132 +++++++++++++++++++++++++++++++++++++++++++ public/js/app.js | 62 ++++++++++++++++++-- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 91fbc39..042631a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -528,6 +528,24 @@ cap-widget { gap: 0; } +.sidebar.collapsed .nav-parent { + font-size: 0; + justify-content: center; + padding: 10px 0; + gap: 0; +} + +.sidebar.collapsed .nav-parent-label, +.sidebar.collapsed .nav-parent-chevron { + display: none; +} + +.sidebar.collapsed .nav-parent svg { + font-size: 1rem; + width: 20px; + height: 20px; +} + .sidebar.collapsed .nav-indicator { left: 8px; width: calc(100% - 16px); @@ -688,6 +706,120 @@ cap-widget { flex-shrink: 0; } +.nav-parent { + 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; + user-select: none; +} + +.nav-parent:hover { + color: var(--text-primary); + background: rgba(238, 129, 50, 0.08); +} + +.nav-parent svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.nav-parent-label { + flex: 1; + min-width: 0; +} + +.nav-parent-chevron { + width: 16px; + height: 16px; + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.nav-parent.open .nav-parent-chevron { + transform: rotate(90deg); +} + +.nav-sub-list { + max-height: 0; + overflow: hidden; + transition: max-height 0.25s ease; + margin-bottom: 2px; +} + +.nav-sub-list.open { + max-height: 400px; +} + +.sidebar.collapsed .nav-sub-list { + display: none; +} + +.nav-sub-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 12px 7px 42px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + transition: color var(--transition); + text-decoration: none; + margin-bottom: 1px; + position: relative; + z-index: 2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-sub-item:hover { + color: var(--text-primary); + background: rgba(238, 129, 50, 0.08); +} + +.nav-sub-item.active { + color: var(--accent-1); +} + +.nav-sub-item .nav-sub-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.nav-sub-dot.dot-active { + background: #22c55e; +} + +.nav-sub-dot.dot-installing { + background: #f59e0b; +} + +.nav-sub-dot.dot-suspended { + background: var(--accent-red); +} + +.nav-sub-empty { + padding: 8px 12px 8px 42px; + font-size: 0.8rem; + color: var(--text-muted); +} + .sidebar-footer { padding: 12px 12px 4px; border-top: 1px solid var(--border); diff --git a/public/js/app.js b/public/js/app.js index 177ba78..77a13cc 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -18,6 +18,8 @@ const state = { notifPanelOpen: false, sidebarMode: 'main', accountTab: 'info', + sidebarServersOpen: false, + sidebarServersLoading: false, }; function setRgpdConsent(preferences) { @@ -1135,7 +1137,7 @@ function initSidebarTooltip() { } sidebarNav.addEventListener('mouseover', (e) => { - const item = e.target.closest('.nav-item'); + const item = e.target.closest('.nav-item, .nav-parent'); if (!item) return; if (!sidebar.classList.contains('collapsed')) return; @@ -1199,10 +1201,27 @@ function renderSidebarNav() { Overview - + + @@ -1255,6 +1274,37 @@ function renderSidebarNav() { }); } + const serversToggle = document.querySelector('#nav-servers-toggle'); + if (serversToggle) { + serversToggle.addEventListener('click', async (e) => { + e.preventDefault(); + state.sidebarServersOpen = !state.sidebarServersOpen; + serversToggle.classList.toggle('open', state.sidebarServersOpen); + const subList = document.querySelector('#nav-servers-list'); + if (subList) subList.classList.toggle('open', state.sidebarServersOpen); + if (state.sidebarServersOpen && state.servers.length === 0 && !state.sidebarServersLoading) { + state.sidebarServersLoading = true; + const subListEl = document.querySelector('#nav-servers-list'); + if (subListEl) subListEl.innerHTML = html``; + try { + const data = await api('/servers/list'); + state.servers = data.servers || []; + } catch (err) { + state.servers = []; + } + state.sidebarServersLoading = false; + renderSidebarNav(); + } + }); + } + + document.querySelectorAll('.nav-sub-item[data-server-nav]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + navigateTo('server/' + item.dataset.serverNav); + }); + }); + updateNavIndicator(); initIcons(); } @@ -1343,6 +1393,8 @@ function navigateTo(page) { if (state.sidebarMode !== newSidebarMode) { state.sidebarMode = newSidebarMode; renderSidebarNav(); + } else if (basePage === 'server') { + renderSidebarNav(); } const url = basePage === 'overview' && !param ? '/' : `/${page}`; @@ -1457,6 +1509,8 @@ window.addEventListener('popstate', () => { if (state.sidebarMode !== newSidebarMode) { state.sidebarMode = newSidebarMode; renderSidebarNav(); + } else if (basePage === 'server') { + renderSidebarNav(); } if (basePage === 'server' && state.serverId) { From b797a6bc866575df8a3d3614120a59a1db1c75c4 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 11 Jul 2026 10:31:07 +0200 Subject: [PATCH 39/51] fix: server dropdown always fetches and navigates to /servers on click --- public/js/app.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 77a13cc..41b514d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1278,11 +1278,10 @@ function renderSidebarNav() { if (serversToggle) { serversToggle.addEventListener('click', async (e) => { e.preventDefault(); - state.sidebarServersOpen = !state.sidebarServersOpen; - serversToggle.classList.toggle('open', state.sidebarServersOpen); - const subList = document.querySelector('#nav-servers-list'); - if (subList) subList.classList.toggle('open', state.sidebarServersOpen); - if (state.sidebarServersOpen && state.servers.length === 0 && !state.sidebarServersLoading) { + if (!state.sidebarServersOpen) { + state.sidebarServersOpen = true; + } + if (state.servers.length === 0 && !state.sidebarServersLoading) { state.sidebarServersLoading = true; const subListEl = document.querySelector('#nav-servers-list'); if (subListEl) subListEl.innerHTML = html``; @@ -1293,8 +1292,9 @@ function renderSidebarNav() { state.servers = []; } state.sidebarServersLoading = false; - renderSidebarNav(); } + navigateTo('servers'); + renderSidebarNav(); }); } From 1f98d8f917c6d453ddc45fa6ca2b866f9bd5ca59 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 11 Jul 2026 10:34:34 +0200 Subject: [PATCH 40/51] feat: add resend verification email button on login error --- public/css/style.css | 28 ++++++++++++++++++++++++++++ public/js/app.js | 28 +++++++++++++++++++++++++++- routes/auth.js | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/public/css/style.css b/public/css/style.css index 042631a..68eee9d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -424,6 +424,34 @@ cap-widget { display: block; } +.auth-error.show:has(.auth-resend-btn) { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-resend-btn { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: var(--accent-red); + padding: 6px 14px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background var(--transition), opacity var(--transition); + align-self: flex-start; +} + +.auth-resend-btn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.25); +} + +.auth-resend-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ===== DASHBOARD LAYOUT ===== */ .dashboard-layout { display: flex; diff --git a/public/js/app.js b/public/js/app.js index 41b514d..8dce509 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -278,7 +278,33 @@ function showToast(message, type = 'success') { function showError(form, message) { const errorEl = form.querySelector('.auth-error'); if (errorEl) { - errorEl.textContent = message; + if (message && message.includes('verify your email')) { + const email = (form.querySelector('#login-email')?.value || '').trim(); + errorEl.innerHTML = html` + ${escapeHtml(message)} + + `; + const resendBtn = errorEl.querySelector('#auth-resend-btn'); + if (resendBtn) { + resendBtn.addEventListener('click', async () => { + if (!email) return; + resendBtn.disabled = true; + resendBtn.textContent = 'Sending...'; + try { + await api('/auth/resend-verification', { + method: 'POST', + body: JSON.stringify({ email }), + }); + resendBtn.textContent = 'Email sent!'; + } catch (err) { + resendBtn.textContent = 'Failed to send'; + resendBtn.disabled = false; + } + }); + } + } else { + errorEl.textContent = message; + } errorEl.classList.add('show'); } } diff --git a/routes/auth.js b/routes/auth.js index ba0f4be..48b01e0 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -300,6 +300,47 @@ router.get('/verify-email', async (req, res) => { } }); +router.post('/resend-verification', async (req, res) => { + try { + const { email } = req.body; + if (!email || typeof email !== 'string') { + return res.status(400).json({ error: 'Email is required' }); + } + + const users = await query('SELECT id, email, username, email_verified FROM users WHERE email = ?', [email]); + if (users.length === 0) { + return res.json({ message: 'If an account with that email exists, a verification link has been sent.' }); + } + + const user = users[0]; + if (user.email_verified) { + return res.json({ message: 'Email already verified. You can now sign in.' }); + } + + const verificationToken = randomBytes(32).toString('hex'); + const tokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await query( + 'UPDATE users SET verification_token = ?, verification_token_expires = ? WHERE id = ?', + [verificationToken, tokenExpires, user.id] + ); + + try { + await sendVerificationEmail(user.email, user.username, verificationToken); + } catch (err) { + console.error('Failed to send verification email:', err.message); + return res.status(500).json({ error: 'Failed to send verification email. Please try again later.' }); + } + + await logActivity(user.id, 'verification_resent', 'Resent verification email'); + + res.json({ message: 'Verification email sent. Check your inbox.' }); + } catch (err) { + console.error('Resend verification error:', err.message); + res.status(500).json({ error: 'Failed to resend verification email' }); + } +}); + router.post('/login', async (req, res) => { try { const { email, password, capToken } = req.body; From f173c7b334145e62cc1a290a093b1d0ff9a1dc6d Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 11 Jul 2026 10:36:23 +0200 Subject: [PATCH 41/51] fix: show nav indicator on My Servers dropdown --- public/js/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 8dce509..0041ec6 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1227,7 +1227,7 @@ function renderSidebarNav() { Overview -