diff --git a/.gitignore b/.gitignore index 21f747b..2098c19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ data/ venv/ +.venv/ __pycache__/ .env *.pyc .DS_Store + diff --git a/main.py b/main.py index 8cc96ae..962ff77 100644 --- a/main.py +++ b/main.py @@ -90,6 +90,16 @@ def init_db(): ) ''') + # Create activity_logs table + c.execute(''' + CREATE TABLE IF NOT EXISTS activity_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL + ) + ''') + # Now check for column migrations c.execute("PRAGMA table_info(instances)") columns = [col[1] for col in c.fetchall()] @@ -135,7 +145,20 @@ def init_db(): conn.commit() conn.close() +def log_activity(level: str, message: str): + try: + conn = get_db() + c = conn.cursor() + c.execute('INSERT INTO activity_logs (timestamp, level, message) VALUES (?, ?, ?)', + (time.time(), level, message)) + conn.commit() + conn.close() + except Exception as e: + print(f"Failed to log activity: {e}") + init_db() +log_activity("INFO", "Database initialized / application startup.") + def is_authenticated(request: Request): if not request.session.get("user_id"): @@ -164,14 +187,20 @@ async def login(request: Request): request.session["user_id"] = user['id'] request.session["username"] = user['username'] request.session["is_admin"] = bool(user['is_admin']) + log_activity("INFO", f"User '{user['username']}' logged in successfully.") return {"status": "ok", "user": {"username": user['username'], "is_admin": bool(user['is_admin'])}} + + log_activity("WARNING", f"Failed login attempt for username '{username}'.") raise HTTPException(status_code=401, detail="Invalid username or password") @app.get("/api/logout") async def logout(request: Request): + username = request.session.get("username", "Unknown") request.session.clear() + log_activity("INFO", f"User '{username}' logged out.") return {"status": "ok"} + @app.get("/api/auth/status") async def auth_status(request: Request): if request.session.get("user_id"): @@ -213,6 +242,8 @@ async def create_user(request: Request): (username, hashed, is_admin)) conn.commit() conn.close() + admin_username = request.session.get("username", "Admin") + log_activity("INFO", f"Admin '{admin_username}' created user '{username}' (Admin: {bool(is_admin)}).") except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="Username already exists") return {"status": "ok"} @@ -223,11 +254,18 @@ def delete_user(id: int, request: Request): raise HTTPException(status_code=400, detail="Cannot delete yourself") conn = get_db() c = conn.cursor() + c.execute('SELECT username FROM users WHERE id=?', (id,)) + target_user_row = c.fetchone() + target_username = target_user_row[0] if target_user_row else f"ID {id}" + c.execute('DELETE FROM users WHERE id=?', (id,)) # Also delete their instances? Let's say yes for cleanliness. c.execute('DELETE FROM instances WHERE user_id=?', (id,)) conn.commit() conn.close() + + admin_username = request.session.get("username", "Admin") + log_activity("INFO", f"Admin '{admin_username}' deleted user '{target_username}' and all associated instances.") return {"status": "ok"} @app.put("/api/users/{id}/password", dependencies=[Depends(is_authenticated), Depends(is_admin)]) @@ -239,11 +277,19 @@ async def admin_reset_password(id: int, request: Request): hashed = get_password_hash(new_password) conn = get_db() c = conn.cursor() + c.execute('SELECT username FROM users WHERE id=?', (id,)) + target_user_row = c.fetchone() + target_username = target_user_row[0] if target_user_row else f"ID {id}" + c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, id)) conn.commit() conn.close() + + admin_username = request.session.get("username", "Admin") + log_activity("INFO", f"Admin '{admin_username}' reset password for user '{target_username}'.") return {"status": "ok"} + # Own Password Change (All Users) @app.put("/api/users/me/password", dependencies=[Depends(is_authenticated)]) async def change_own_password(request: Request): @@ -252,12 +298,15 @@ async def change_own_password(request: Request): if not new_password: raise HTTPException(status_code=400, detail="New password required") user_id = request.session.get("user_id") + username = request.session.get("username") hashed = get_password_hash(new_password) conn = get_db() c = conn.cursor() c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, user_id)) conn.commit() conn.close() + + log_activity("INFO", f"User '{username}' updated their password.") return {"status": "ok"} @app.put("/api/users/me/username", dependencies=[Depends(is_authenticated)]) @@ -267,16 +316,20 @@ async def change_own_username(request: Request): if not new_username: raise HTTPException(status_code=400, detail="New username required") user_id = request.session.get("user_id") + old_username = request.session.get("username") try: conn = get_db() c = conn.cursor() c.execute('UPDATE users SET username = ? WHERE id = ?', (new_username, user_id)) conn.commit() conn.close() + request.session["username"] = new_username + log_activity("INFO", f"User '{old_username}' updated their username to '{new_username}'.") except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="Username already exists") return {"status": "ok"} + class OTPSecretCreate(BaseModel): name: str secret: str @@ -324,6 +377,7 @@ def get_otp_secrets(request: Request): @app.post("/api/otp-secrets", dependencies=[Depends(is_authenticated)]) def create_otp_secret(secret_data: OTPSecretCreate, request: Request): user_id = request.session.get("user_id") + username = request.session.get("username") cleaned = clean_secret(secret_data.secret) encrypted = fernet.encrypt(cleaned.encode()).decode() conn = get_db() @@ -334,19 +388,23 @@ def create_otp_secret(secret_data: OTPSecretCreate, request: Request): ''', (secret_data.name, encrypted, user_id)) conn.commit() conn.close() + log_activity("INFO", f"User '{username}' created OTP secret '{secret_data.name}'.") return {"status": "ok"} @app.put("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)]) def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request): user_id = request.session.get("user_id") + username = request.session.get("username") conn = get_db() c = conn.cursor() # Verify ownership - c.execute('SELECT id FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) - if not c.fetchone(): + c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) + row = c.fetchone() + if not row: conn.close() raise HTTPException(status_code=404, detail="OTP Secret not found") + old_name = row[0] if secret_data.secret: cleaned = clean_secret(secret_data.secret) @@ -355,11 +413,13 @@ def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request): UPDATE otp_secrets SET name=?, encrypted_secret=? WHERE id=? AND user_id=? ''', (secret_data.name, encrypted, id, user_id)) + log_activity("INFO", f"User '{username}' updated OTP secret '{old_name}' (name updated to '{secret_data.name}' and key value changed).") else: c.execute(''' UPDATE otp_secrets SET name=? WHERE id=? AND user_id=? ''', (secret_data.name, id, user_id)) + log_activity("INFO", f"User '{username}' updated OTP secret name '{old_name}' to '{secret_data.name}'.") conn.commit() conn.close() @@ -368,9 +428,18 @@ def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request): @app.delete("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)]) def delete_otp_secret(id: int, request: Request): user_id = request.session.get("user_id") + username = request.session.get("username") conn = get_db() c = conn.cursor() + # Verify ownership + c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) + row = c.fetchone() + if not row: + conn.close() + raise HTTPException(status_code=404, detail="OTP Secret not found") + secret_name = row[0] + # Check if used by any instances c.execute('SELECT COUNT(*) FROM instances WHERE otp_secret_id=?', (id,)) if c.fetchone()[0] > 0: @@ -380,6 +449,7 @@ def delete_otp_secret(id: int, request: Request): c.execute('DELETE FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) conn.commit() conn.close() + log_activity("INFO", f"User '{username}' deleted OTP secret '{secret_name}'.") return {"status": "ok"} async def poll_instances(): @@ -419,13 +489,17 @@ async def poll_instances(): if resp.status_code == 200: status_msg = "Sent" + log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - OTP sent successfully.") else: status_msg = f"Failed: {resp.status_code}" + log_activity("WARNING", f"Polled instance '{inst['name']}' ({ip}:{port}) - Failed: HTTP status {resp.status_code}.") except (httpx.ConnectError, httpx.TimeoutException): status_msg = "Offline" + log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - Offline (no response on port {port}).") except Exception as e: status_msg = f"Error: {str(e)}" + log_activity("ERROR", f"Polled instance '{inst['name']}' ({ip}:{port}) - Error: {str(e)}.") conn = get_db() c = conn.cursor() @@ -464,6 +538,7 @@ def get_instances(request: Request): @app.post("/api/instances", dependencies=[Depends(is_authenticated)]) def create_instance(inst: InstanceCreate, request: Request): user_id = request.session.get("user_id") + username = request.session.get("username") conn = get_db() c = conn.cursor() c.execute(''' @@ -472,19 +547,23 @@ def create_instance(inst: InstanceCreate, request: Request): ''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, user_id, "")) conn.commit() conn.close() + log_activity("INFO", f"User '{username}' created instance '{inst.name}' ({inst.ip}:{inst.port}).") return {"status": "ok"} @app.put("/api/instances/{id}", dependencies=[Depends(is_authenticated)]) def update_instance(id: int, inst: InstanceUpdate, request: Request): user_id = request.session.get("user_id") + username = request.session.get("username") conn = get_db() c = conn.cursor() # Verify ownership - c.execute('SELECT id FROM instances WHERE id=? AND user_id=?', (id, user_id)) - if not c.fetchone(): + c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id)) + row = c.fetchone() + if not row: conn.close() raise HTTPException(status_code=404, detail="Instance not found") + old_name = row[0] c.execute(''' UPDATE instances SET name=?, ip=?, port=?, otp_secret_id=? @@ -493,22 +572,44 @@ def update_instance(id: int, inst: InstanceUpdate, request: Request): conn.commit() conn.close() + log_activity("INFO", f"User '{username}' updated instance '{old_name}' -> '{inst.name}' ({inst.ip}:{inst.port}).") return {"status": "ok"} -@app.get("/api/config", dependencies=[Depends(is_authenticated)]) -def get_config(): - return {"firewall_host_ip": FIREWALL_HOST_IP} - @app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)]) def delete_instance(id: int, request: Request): user_id = request.session.get("user_id") + username = request.session.get("username") conn = get_db() c = conn.cursor() + + c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id)) + row = c.fetchone() + if not row: + conn.close() + raise HTTPException(status_code=404, detail="Instance not found") + inst_name = row[0] + c.execute('DELETE FROM instances WHERE id=? AND user_id=?', (id, user_id)) conn.commit() conn.close() + log_activity("INFO", f"User '{username}' deleted instance '{inst_name}'.") return {"status": "ok"} +@app.get("/api/config") +def get_config(): + return {"firewall_host_ip": FIREWALL_HOST_IP} + +@app.get("/api/logs", dependencies=[Depends(is_authenticated)]) +def get_logs(): + conn = get_db() + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute('SELECT id, timestamp, level, message FROM activity_logs ORDER BY id DESC LIMIT 100') + logs = [dict(row) for row in c.fetchall()] + conn.close() + return logs + + # Mount root and static @app.get("/") def read_root(): diff --git a/static/app.js b/static/app.js index 9a637df..023b979 100644 --- a/static/app.js +++ b/static/app.js @@ -31,8 +31,13 @@ document.addEventListener('DOMContentLoaded', () => { const addOtpModal = document.getElementById('add-otp-secret-modal'); const otpSecretIdSelect = document.getElementById('otp-secret-id'); const otpSecretsList = document.getElementById('otp-secrets-list'); + const logsToggle = document.getElementById('logs-toggle'); + const logsSection = document.getElementById('logs-section'); + const logsViewer = document.getElementById('logs-viewer'); + const logsRefreshBtn = document.getElementById('logs-refresh-btn'); let config = { firewall_host_ip: null }; + let logsInterval = null; let currentUser = null; let editId = null; let editOtpId = null; @@ -87,12 +92,18 @@ document.addEventListener('DOMContentLoaded', () => { loginOverlay.style.display = 'flex'; logoutBtn.style.display = 'none'; mainNav.style.display = 'none'; + if (logsSection) logsSection.style.display = 'none'; + stopLogsPolling(); }; const hideLogin = () => { loginOverlay.style.display = 'none'; logoutBtn.style.display = 'flex'; mainNav.style.display = 'flex'; + if (logsToggle && logsToggle.checked) { + if (logsSection) logsSection.style.display = 'block'; + startLogsPolling(); + } }; // Tab Switching @@ -110,9 +121,15 @@ document.addEventListener('DOMContentLoaded', () => { }); const updateFirewallCmds = () => { + const hostIp = config.firewall_host_ip || window.location.hostname; + + const loginFirewallIp = document.getElementById('login-firewall-ip'); + if (loginFirewallIp) { + loginFirewallIp.textContent = hostIp; + } + if (!ufwCmd || !iptablesCmd || !winCmd) return; const port = portInput.value || '4646'; - const hostIp = config.firewall_host_ip || window.location.hostname; ufwCmd.textContent = `sudo ufw allow from ${hostIp} to any port ${port} proto tcp`; iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp -s ${hostIp} --dport ${port} -j ACCEPT`; winCmd.textContent = `New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress ${hostIp} -LocalPort ${port} -Protocol TCP -Action Allow`; @@ -121,7 +138,6 @@ document.addEventListener('DOMContentLoaded', () => { const fetchConfig = async () => { try { const res = await fetch('/api/config'); - if (res.status === 401) return showLogin(); if (res.ok) { config = await res.json(); updateFirewallCmds(); @@ -193,13 +209,10 @@ document.addEventListener('DOMContentLoaded', () => {
@@ -220,10 +233,10 @@ document.addEventListener('DOMContentLoaded', () => {
@@ -238,10 +251,10 @@ document.addEventListener('DOMContentLoaded', () => {
@@ -514,7 +527,7 @@ document.addEventListener('DOMContentLoaded', () => { await navigator.clipboard.writeText(text); const originalHtml = btn.innerHTML; btn.classList.add('copied'); - btn.innerHTML = ''; + btn.innerHTML = ''; setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = originalHtml; @@ -526,6 +539,79 @@ document.addEventListener('DOMContentLoaded', () => { portInput.addEventListener('input', updateFirewallCmds); + // Bind help button on login overlay + const loginShowHelpBtn = document.getElementById('login-show-help-btn'); + if (loginShowHelpBtn) { + loginShowHelpBtn.addEventListener('click', () => openModal(helpModal)); + } + + // Logging helpers + const fetchLogs = async () => { + if (!currentUser) return; + try { + const res = await fetch('/api/logs'); + if (res.status === 401) return showLogin(); + if (res.ok) { + const logs = await res.json(); + renderLogs(logs); + } + } catch (e) { + console.error('Failed to fetch logs:', e); + } + }; + + const startLogsPolling = () => { + stopLogsPolling(); + fetchLogs(); + logsInterval = setInterval(fetchLogs, 5000); + }; + + const stopLogsPolling = () => { + if (logsInterval) { + clearInterval(logsInterval); + logsInterval = null; + } + }; + + const renderLogs = (logs) => { + if (!logsViewer) return; + if (logs.length === 0) { + logsViewer.innerHTML = '
No logs available.
'; + return; + } + + logsViewer.innerHTML = logs.slice().reverse().map(log => { + const date = new Date(log.timestamp * 1000); + const timeStr = date.toLocaleTimeString() + '.' + String(date.getMilliseconds()).padStart(3, '0'); + const levelClass = `log-level-${log.level.toLowerCase()}`; + return ` +
+ [${timeStr}] + [${log.level}] + ${escapeHtml(log.message)} +
+ `; + }).join(''); + + logsViewer.scrollTop = logsViewer.scrollHeight; + }; + + if (logsToggle) { + logsToggle.addEventListener('change', () => { + if (logsToggle.checked) { + logsSection.style.display = 'block'; + startLogsPolling(); + } else { + logsSection.style.display = 'none'; + stopLogsPolling(); + } + }); + } + + if (logsRefreshBtn) { + logsRefreshBtn.addEventListener('click', fetchLogs); + } + const checkAuth = async () => { try { const res = await fetch('/api/auth/status'); @@ -534,7 +620,7 @@ document.addEventListener('DOMContentLoaded', () => { currentUser = data.user; setupUIForUser(); hideLogin(); - fetchConfig(); + updateFirewallCmds(); fetchInstances(); fetchOTPSecrets(); } else { @@ -555,7 +641,10 @@ document.addEventListener('DOMContentLoaded', () => { .replace(/'/g, "'"); } + // Unconditionally fetch config on startup to show firewall info on login overlay + fetchConfig(); checkAuth(); + setInterval(() => { if (loginOverlay.style.display === 'none' && document.getElementById('instances-section').classList.contains('active')) { fetchInstances(); diff --git a/static/index.html b/static/index.html index c65365e..3dc0027 100644 --- a/static/index.html +++ b/static/index.html @@ -9,28 +9,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -50,13 +116,15 @@

Managed Instances

@@ -72,7 +140,10 @@

OTP Secrets

@@ -87,7 +158,10 @@

User Management

@@ -122,6 +196,28 @@
+ +
+ +
+ + @@ -216,7 +312,7 @@
sudo ufw allow from localhost to any port 4646 proto tcp
@@ -225,7 +321,7 @@
sudo iptables -I INPUT -p tcp -s localhost --dport 4646 -j ACCEPT
@@ -234,7 +330,7 @@
New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress localhost -LocalPort 4646 -Protocol TCP -Action Allow
@@ -244,9 +340,12 @@
@@ -262,6 +361,23 @@ +
+
diff --git a/static/style.css b/static/style.css index c23e539..fa81d14 100644 --- a/static/style.css +++ b/static/style.css @@ -1,14 +1,14 @@ :root { - --bg-color: #0f172a; - --card-bg: #1e293b; - --primary: #3b82f6; - --primary-hover: #2563eb; - --text-main: #f8fafc; - --text-muted: #94a3b8; - --border-color: #334155; - --status-online: #10b981; - --status-offline: #ef4444; - --status-sent: #3b82f6; + --bg-color: #e3e2e1; + --card-bg: #fcfcfc; + --primary: #2b7bc4; + --primary-hover: #3daee9; + --text-main: #232629; + --text-muted: #5f6265; + --border-color: #bcbcbc; + --status-online: #2ecc71; + --status-offline: #e74c3c; + --status-sent: #2b7bc4; } * { @@ -18,26 +18,447 @@ } body { - font-family: 'Inter', sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-color); color: var(--text-main); line-height: 1.5; -webkit-font-smoothing: antialiased; + padding-bottom: 3rem; } .container { max-width: 800px; margin: 0 auto; - padding: 2rem; + padding: 2rem 1.5rem; } +/* Header styled as a desktop panel */ header { + background: linear-gradient(to bottom, #f5f5f5 0%, #e1e1e0 100%); + border: 1px solid #acacac; + border-radius: 6px; + padding: 0.75rem 1.25rem; margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: center; + box-shadow: inset 0 1px 0 #ffffff, 0 1px 3px rgba(0,0,0,0.08); } +.logo { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logo h1 { + font-size: 1.25rem; + font-weight: 600; + color: #232629; + text-shadow: 0 1px 0 #ffffff; +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +/* Nav styled as classic Oxygen folder tabs */ +.main-nav { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0; + padding-left: 0.5rem; +} + +.nav-item { + background: linear-gradient(to bottom, #eaeaea 0%, #dcdcdc 100%); + border: 1px solid var(--border-color); + border-bottom: none; + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + padding: 0.5rem 1.25rem; + border-radius: 5px 5px 0 0; + position: relative; + top: 1px; + transition: all 0.15s ease-in-out; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.4); + text-shadow: 0 1px 0 rgba(255,255,255,0.6); +} + +.nav-item:hover { + color: var(--text-main); + background: linear-gradient(to bottom, #fafafa 0%, #e2e2e2 100%); +} + +.nav-item.active { + color: #2b7bc4; + background: #fcfcfc; + border-bottom: 1px solid #fcfcfc; + box-shadow: inset 0 1px 0 rgba(255,255,255,1); + top: 2px; + z-index: 2; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Cards styled as native program groupboxes */ +.card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255,255,255,0.9); +} + +.card h2 { + font-size: 1.15rem; + margin-bottom: 1.25rem; + font-weight: 600; + color: #232629; + border-bottom: 1px solid #dcdcdc; + padding-bottom: 0.5rem; + text-shadow: 0 1px 0 #ffffff; +} + +/* Oxygen Button styling */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1.25rem; + font-weight: 500; + font-size: 0.9rem; + border-radius: 5px; + cursor: pointer; + border: 1px solid #a8a8a8; + background: linear-gradient(to bottom, #fcfcfc 0%, #e0e0e0 40%, #d5d5d5 100%); + color: #232629; + box-shadow: 0 1px 2px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.7); + transition: all 0.15s ease-in-out; + font-family: inherit; + text-shadow: 0 1px 0 rgba(255,255,255,0.6); +} + +.btn:hover { + border-color: #3daee9; + background: linear-gradient(to bottom, #ffffff 0%, #ebf5fb 40%, #d6eaf8 100%); + box-shadow: 0 1px 4px rgba(61, 174, 233, 0.25), inset 0 1px 0 rgba(255,255,255,0.9); +} + +.btn:active { + background: linear-gradient(to bottom, #d5d5d5 0%, #e5e5e5 100%); + box-shadow: inset 0 1px 3px rgba(0,0,0,0.15); + transform: translateY(0.5px); +} + +.btn-primary { + background: linear-gradient(to bottom, #73b9e5 0%, #2e8bdc 40%, #1c6fae 100%); + border-color: #1a5e95; + color: #ffffff; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); + box-shadow: 0 1px 2px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.3); +} + +.btn-primary:hover { + background: linear-gradient(to bottom, #89c7eb 0%, #3daee9 40%, #2184be 100%); + border-color: #1a5e95; + color: #ffffff; + box-shadow: 0 1px 4px rgba(61, 174, 233, 0.35), inset 0 1px 0 rgba(255,255,255,0.4); +} + +.btn-primary:active { + background: linear-gradient(to bottom, #1c6fae 0%, #2e8bdc 100%); + box-shadow: inset 0 1px 4px rgba(0,0,0,0.3); +} + +.btn-secondary { + background: linear-gradient(to bottom, #fafafa 0%, #e5e7eb 40%, #d1d5db 100%); + border-color: #9ca3af; +} + +.btn-icon { + padding: 0.45rem; + border-radius: 5px; + border: 1px solid transparent; + background: transparent; + box-shadow: none; +} +.btn-icon:hover { + border-color: #b0b0b0; + background: linear-gradient(to bottom, #ffffff 0%, #eaeaea 100%); +} + +.btn-sm { + padding: 0.35rem 0.75rem; + font-size: 0.8rem; +} + +/* Form inputs with 3D inset shadows */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-row { + display: flex; + gap: 1rem; +} + +.form-row .form-group { + flex: 1; +} + +label { + display: block; + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.375rem; + font-weight: 600; +} + +input, select { + width: 100%; + background-color: #ffffff; + border: 1px solid #b8b8b8; + color: #232629; + padding: 0.55rem 0.75rem; + border-radius: 4px; + font-family: inherit; + font-size: 0.95rem; + box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.12); + transition: border-color 0.15s, box-shadow 0.15s; +} + +input:focus, select:focus { + outline: none; + border-color: #3daee9; + box-shadow: 0 0 5px rgba(61, 174, 233, 0.6), inset 1px 1px 2px rgba(0, 0, 0, 0.1); +} + +.divider { + height: 1px; + background-color: #dcdcdc; + margin: 1.5rem 0; +} + +/* Modals styled as native windows */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 2000; + justify-content: center; + align-items: center; + padding: 1rem; +} + +.modal.active { + display: flex; +} + +.modal-content { + background-color: var(--card-bg); + border: 1px solid #909090; + border-radius: 6px; + box-shadow: 0 10px 40px rgba(0,0,0,0.25), inset 0 1px 0 #ffffff; + overflow: hidden; + padding: 0; + width: 100%; + max-width: 500px; +} + +.modal-header { + background: linear-gradient(to bottom, #eff2f5 0%, #dcdfe3 100%); + border-bottom: 1px solid #b0b0b0; + padding: 0.75rem 1.25rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: inset 0 1px 0 #ffffff; +} + +.modal-header h2 { + font-size: 1.05rem; + font-weight: 600; + color: #232629; + margin: 0; + text-shadow: 0 1px 0 #ffffff; + border-bottom: none; + padding-bottom: 0; +} + +/* Styled close button as red Oxygen ball window close */ +.btn-close { + width: 18px; + height: 18px; + background: radial-gradient(cx 30%, cy 30%, #ff8888 0%, #ef4444 60%, #cc0000 100%); + border: 1px solid #990000; + border-radius: 50%; + color: transparent; + cursor: pointer; + position: relative; + box-shadow: 0 1px 2px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4); +} + +.btn-close:hover { + background: radial-gradient(cx 30%, cy 30%, #ffaaaa 0%, #ff4d4d 60%, #e60000 100%); +} + +.btn-close::before, .btn-close::after { + content: ''; + position: absolute; + width: 8px; + height: 2px; + background-color: white; + top: 7px; + left: 4px; +} +.btn-close::before { transform: rotate(45deg); } +.btn-close::after { transform: rotate(-45deg); } + +.modal-content form { + padding: 1.5rem; +} + +.modal-content .helper-text { + padding: 1.25rem 1.5rem 0 1.5rem; + margin-bottom: 0; +} + +.modal-content .code-block { + margin: 1rem 1.5rem; +} + +/* User management / lists items */ +.instances-list, .otp-secrets-list, .users-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.instance-item, .otp-secret-item, .user-item { + background: linear-gradient(to bottom, #fbfbfb 0%, #f1f1f1 100%); + border: 1px solid #cfcfcf; + border-radius: 5px; + padding: 0.875rem 1.25rem; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s ease-in-out; + box-shadow: inset 0 1px 0 #ffffff; +} + +.instance-item:hover, .otp-secret-item:hover, .user-item:hover { + border-color: #3daee9; + background: linear-gradient(to bottom, #f2f9fe 0%, #e1f0fa 100%); + box-shadow: 0 1px 4px rgba(61, 174, 233, 0.15), inset 0 1px 0 #ffffff; +} + +.instance-info h3, .otp-secret-info h3, .user-info h3 { + font-size: 0.95rem; + font-weight: 600; + color: #232629; +} + +.instance-meta { + font-size: 0.8rem; + color: var(--text-muted); + display: flex; + gap: 1rem; + margin-top: 0.25rem; + flex-wrap: wrap; +} + +.instance-actions, .otp-secret-actions, .user-actions { + display: flex; + gap: 0.375rem; +} + +/* Badges */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.7rem; + font-weight: 700; + padding: 0.15rem 0.5rem; + border-radius: 4px; + background-color: #e2e8f0; + border: 1px solid #cbd5e1; + color: #475569; +} + +.status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; +} + +.status-badge.status-offline { + background-color: #fee2e2; + border-color: #fca5a5; + color: #991b1b; +} +.status-badge.status-offline::before { background-color: var(--status-offline); } + +.status-badge.status-sent { + background-color: #dbeafe; + border-color: #93c5fd; + color: #1e40af; +} +.status-badge.status-sent::before { background-color: var(--status-sent); } + +.status-badge.status-unknown::before { background-color: var(--text-muted); } + +/* Firewall Help Modal Code Blocks */ +.code-block { + background-color: #fafafa; + border: 1px solid #bcbcbc; + border-radius: 4px; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + box-shadow: inset 1px 1px 2px rgba(0,0,0,0.05); +} + +.code-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.code-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.code-block code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 0.8rem; + color: #2b7bc4; + word-break: break-all; +} + +/* Login Screen */ .login-overlay { position: fixed; top: 0; @@ -53,489 +474,218 @@ header { .login-card { background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 2rem; + border: 1px solid #909090; + border-radius: 6px; width: 100%; max-width: 400px; - box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3); -} - -.main-nav { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - border-bottom: 1px solid var(--border-color); - padding-bottom: 1rem; -} - -.nav-item { - background: transparent; - border: none; - color: var(--text-muted); - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - padding: 0.5rem 1rem; - border-radius: 6px; - transition: all 0.2s; -} - -.nav-item:hover { - color: var(--text-main); - background-color: rgba(255, 255, 255, 0.05); -} - -.nav-item.active { - color: var(--primary); - background-color: rgba(59, 130, 246, 0.1); -} - -.tab-content { - display: none; -} - -.tab-content.active { - display: block; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), inset 0 1px 0 #ffffff; + overflow: hidden; + padding: 0; } .login-card .logo { - justify-content: center; - margin-bottom: 2rem; + background: linear-gradient(to bottom, #eff2f5 0%, #dcdfe3 100%); + border-bottom: 1px solid #b0b0b0; + padding: 0.85rem 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + box-shadow: inset 0 1px 0 #ffffff; +} + +.login-card .logo h2 { + font-size: 1.1rem; + font-weight: 600; + color: #232629; + text-shadow: 0 1px 0 #ffffff; +} + +.login-card form { + padding: 1.5rem 1.5rem 0 1.5rem; +} + +.login-card .login-firewall-section { + padding: 0 1.5rem 1.5rem 1.5rem; } .login-error { color: var(--status-offline); - font-size: 0.875rem; + font-size: 0.85rem; margin-top: 1rem; text-align: center; -} - -.logo { - display: flex; - align-items: center; - gap: 1rem; -} - -.logo svg { - color: var(--primary); -} - -.logo h1 { - font-size: 1.5rem; font-weight: 600; } -.card { +/* Konsole-styled System Logs Panel */ +.logs-toggle-container { + margin-top: 2rem; + margin-bottom: 1rem; + display: flex; + justify-content: flex-end; +} + +/* Styled switch checkbox */ +.switch-container { + display: inline-flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.switch-container input { + display: none; +} + +.switch-slider { + position: relative; + width: 36px; + height: 20px; + background-color: #bdc3c7; + border-radius: 10px; + transition: background-color 0.2s; + border: 1px solid #a8a8a8; +} + +.switch-slider::before { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: white; + transition: transform 0.2s; + box-shadow: 0 1px 2px rgba(0,0,0,0.2); +} + +.switch-container input:checked + .switch-slider { + background-color: #3daee9; + border-color: #2b7bc4; +} + +.switch-container input:checked + .switch-slider::before { + transform: translateX(16px); +} + +.switch-label { + font-size: 0.8rem; + color: var(--text-muted); + font-weight: 600; +} + +.logs-card { background-color: var(--card-bg); border: 1px solid var(--border-color); - border-radius: 12px; - padding: 1.5rem; - margin-bottom: 2rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); -} - -.card h2 { - font-size: 1.25rem; - margin-bottom: 1.5rem; - font-weight: 600; -} - -.form-group { - margin-bottom: 1rem; -} - -.form-row { - display: flex; - gap: 1rem; -} - -.form-row .form-group { - flex: 1; -} - -label { - display: block; - font-size: 0.875rem; - color: var(--text-muted); - margin-bottom: 0.5rem; - font-weight: 500; -} - -input, select { - width: 100%; - background-color: var(--bg-color); - border: 1px solid var(--border-color); - color: var(--text-main); - padding: 0.75rem 1rem; - border-radius: 8px; - font-family: inherit; - font-size: 1rem; - transition: border-color 0.2s, box-shadow 0.2s; -} - -input:focus, select:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25); -} - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.75rem 1.5rem; - font-weight: 500; - border-radius: 8px; - cursor: pointer; - border: none; - transition: background-color 0.2s, transform 0.1s; - font-family: inherit; - font-size: 1rem; -} - -.btn-primary { - background-color: var(--primary); - color: white; -} - -.login-card .btn-primary { - width: 100%; + border-radius: 6px; + padding: 0; margin-top: 1rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + overflow: hidden; } -.btn-primary:hover { - background-color: var(--primary-hover); -} - -.btn-primary:active { - transform: translateY(1px); -} - -.btn-icon { - padding: 0.5rem; - background: transparent; - color: var(--text-muted); - border-radius: 6px; -} - -.btn-icon:hover { - background-color: rgba(255, 255, 255, 0.1); - color: var(--text-main); -} - -.instances-header { +.logs-header { + background: linear-gradient(to bottom, #eff2f5 0%, #dcdfe3 100%); + border-bottom: 1px solid #b0b0b0; + padding: 0.5rem 1rem; display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + box-shadow: inset 0 1px 0 #ffffff; } -.instances-header h2 { - margin-bottom: 0; -} - -.instances-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.instance-item { - background-color: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 1rem; - display: flex; - justify-content: space-between; - align-items: center; - transition: transform 0.2s; -} - -.instance-item:hover { - transform: translateX(4px); - border-color: rgba(255, 255, 255, 0.2); -} - -.instance-info h3 { - font-size: 1rem; - margin-bottom: 0.25rem; -} - -.instance-meta { - font-size: 0.875rem; - color: var(--text-muted); - display: flex; - gap: 1rem; -} - -.status-badge { - display: inline-flex; - align-items: center; - gap: 0.375rem; - font-size: 0.75rem; +.logs-header h3 { + font-size: 0.9rem; font-weight: 600; - padding: 0.25rem 0.5rem; - border-radius: 999px; - background-color: rgba(255, 255, 255, 0.1); + color: #232629; + text-shadow: 0 1px 0 #ffffff; } -.status-badge::before { - content: ''; - width: 8px; - height: 8px; - border-radius: 50%; -} - -.status-badge.status-offline::before { background-color: var(--status-offline); } -.status-badge.status-sent::before { background-color: var(--status-sent); } -.status-badge.status-unknown::before { background-color: var(--text-muted); } - -.delete-btn { - color: var(--status-offline); - background: transparent; +.logs-viewer { + background-color: #151515; border: none; - padding: 0.5rem; - cursor: pointer; - border-radius: 6px; - transition: background-color 0.2s; + border-radius: 0; + padding: 0.75rem 1rem; + height: 220px; + overflow-y: auto; + font-family: 'Source Code Pro', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + line-height: 1.5; + color: #f0f0f0; } -.delete-btn:hover { - background-color: rgba(239, 68, 68, 0.1); +.logs-viewer::-webkit-scrollbar { + width: 10px; +} +.logs-viewer::-webkit-scrollbar-track { + background: #1e1e1e; +} +.logs-viewer::-webkit-scrollbar-thumb { + background: #4a4a4a; + border: 2px solid #1e1e1e; + border-radius: 5px; +} +.logs-viewer::-webkit-scrollbar-thumb:hover { + background: #606060; +} + +.log-entry { + margin-bottom: 0.25rem; + word-break: break-all; + white-space: pre-wrap; + border-bottom: 1px solid #252525; + padding-bottom: 0.25rem; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-time { + color: #7f8c8d; + margin-right: 0.5rem; +} + +.log-level { + font-weight: 700; + margin-right: 0.5rem; +} + +.log-level-info { + color: #00a2e8; +} + +.log-level-warning { + color: #ffc90e; +} + +.log-level-error { + color: #ed1c24; +} + +.log-message { + color: #e4e4e4; +} + +/* Helpers */ +.helper-text { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.75rem; } .empty-state { text-align: center; - padding: 2rem; + padding: 2.5rem; color: var(--text-muted); -} - -.helper-text { - font-size: 0.875rem; - color: var(--text-muted); - margin-bottom: 1rem; -} - -.code-block { - background-color: #0f172a; - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 0.75rem; - margin-bottom: 0.75rem; - position: relative; - font-family: monospace; - font-size: 0.875rem; - display: flex; - flex-direction: column; -} - -.code-label { - font-size: 0.7rem; - color: var(--text-muted); - text-transform: uppercase; - font-weight: 700; - margin-bottom: 0.25rem; - font-family: 'Inter', sans-serif; -} - -.code-block code { - color: #34d399; - word-break: break-all; -} - -.code-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; -} - -.btn-copy { - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 0.25rem; - border-radius: 4px; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-copy:hover { - background-color: rgba(255, 255, 255, 0.1); - color: var(--text-main); -} - -.btn-copy:active { - transform: scale(0.95); -} - -.btn-copy.copied { - color: var(--status-online); -} - -.users-list, .otp-secrets-list { - margin-top: 2rem; - display: flex; - flex-direction: column; - gap: 1rem; -} - -.user-item, .otp-secret-item { - background-color: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 1rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.user-info h3, .otp-secret-info h3 { - font-size: 1rem; - margin-bottom: 0.25rem; -} - -.user-actions, .otp-secret-actions { - display: flex; - gap: 0.5rem; -} - -.btn-sm { - padding: 0.35rem 0.75rem; - font-size: 0.75rem; - height: auto; -} - -.user-create-form, #add-form { - display: flex; - flex-direction: column; - gap: 1.25rem; - width: 100%; -} - -.user-create-form .btn-primary, #add-form .btn-primary { - align-self: flex-end; - margin-top: 0.5rem; -} - -.checkbox-container { - display: flex; - align-items: center; - gap: 0.75rem; - cursor: pointer; - user-select: none; - padding: 0.5rem 0; -} - -.checkbox-container input { - width: 18px; - height: 18px; - accent-color: var(--primary); - cursor: pointer; -} - -.checkbox-label { - font-size: 0.875rem; - color: var(--text-muted); -} - -.profile-form { - display: flex; - flex-direction: column; - gap: 1.25rem; - max-width: 400px; -} - -.profile-form .btn-primary { - align-self: flex-start; -} - -.divider { - height: 1px; - background-color: var(--border-color); - margin: 2rem 0; + font-style: italic; + font-size: 0.9rem; } .success-msg { color: var(--status-online); + font-weight: 600; text-align: center; margin-top: 1rem; - font-size: 0.875rem; -} - -/* Modals */ -.modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); - z-index: 2000; - justify-content: center; - align-items: center; - padding: 1rem; -} - -.modal.active { - display: flex; -} - -.modal-content { - width: 100%; - max-width: 500px; - margin: 0; - position: relative; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.modal-header h2 { - margin: 0; -} - -.btn-close { - background: transparent; - border: none; - color: var(--text-muted); - font-size: 1.5rem; - cursor: pointer; - line-height: 1; -} - -.btn-close:hover { - color: var(--text-main); -} - -.header-actions { - display: flex; - gap: 0.5rem; -} - -.instances-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.instances-header .actions { - display: flex; - gap: 0.5rem; -} - -.btn-sm { - padding: 0.4rem 0.8rem; - font-size: 0.75rem; + font-size: 0.85rem; }