diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx index 38f3aac..96e3b14 100644 --- a/AppImage/components/login.tsx +++ b/AppImage/components/login.tsx @@ -2,10 +2,11 @@ import type React from "react" -import { useState } from "react" +import { useState, useEffect } from "react" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" +import { Checkbox } from "./ui/checkbox" import { Lock, User, AlertCircle, Server } from "lucide-react" import { getApiUrl } from "../lib/api-config" import Image from "next/image" @@ -17,15 +18,49 @@ interface LoginProps { export function Login({ onLogin }: LoginProps) { const [username, setUsername] = useState("") const [password, setPassword] = useState("") + const [rememberMe, setRememberMe] = useState(false) const [error, setError] = useState("") const [loading, setLoading] = useState(false) + useEffect(() => { + const savedUsername = localStorage.getItem("proxmenux-saved-username") + const savedPassword = localStorage.getItem("proxmenux-saved-password") + + if (savedUsername && savedPassword) { + setUsername(savedUsername) + setPassword(savedPassword) + setRememberMe(true) + + // Auto-login si hay credenciales guardadas + handleAutoLogin(savedUsername, savedPassword) + } + }, []) + + const handleAutoLogin = async (user: string, pass: string) => { + try { + const response = await fetch(getApiUrl("/api/auth/login"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: user, password: pass, remember_me: true }), + }) + + const data = await response.json() + + if (response.ok && data.token) { + localStorage.setItem("proxmenux-auth-token", data.token) + onLogin() + } + } catch (err) { + console.log("Auto-login failed, showing login form") + } + } + const handleLogin = async (e: React.FormEvent) => { e.preventDefault() setError("") if (!username || !password) { - setError("Please enter username and password") + setError("Por favor, introduce usuario y contraseña") return } @@ -35,20 +70,30 @@ export function Login({ onLogin }: LoginProps) { const response = await fetch(getApiUrl("/api/auth/login"), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username, password, remember_me: rememberMe }), }) const data = await response.json() if (!response.ok) { - throw new Error(data.error || "Login failed") + throw new Error(data.error || "Fallo en el login") } // Save token localStorage.setItem("proxmenux-auth-token", data.token) + + if (rememberMe) { + localStorage.setItem("proxmenux-saved-username", username) + localStorage.setItem("proxmenux-saved-password", password) + } else { + // Limpiar credenciales guardadas si no se marcó recordar + localStorage.removeItem("proxmenux-saved-username") + localStorage.removeItem("proxmenux-saved-password") + } + onLogin() } catch (err) { - setError(err instanceof Error ? err.message : "Login failed") + setError(err instanceof Error ? err.message : "Fallo en el login") } finally { setLoading(false) } @@ -128,6 +173,18 @@ export function Login({ onLogin }: LoginProps) { +
+ setRememberMe(checked as boolean)} + disabled={loading} + /> + +
+ diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index 9cce96a..5090510 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -1,5 +1,7 @@ "use client" +import type React from "react" + import { useState, useEffect, useMemo, useCallback } from "react" import { Badge } from "./ui/badge" import { Button } from "./ui/button" @@ -28,6 +30,7 @@ import { Cpu, FileText, SettingsIcon, + LogOut, } from "lucide-react" import Image from "next/image" import { ThemeToggle } from "./theme-toggle" @@ -74,6 +77,7 @@ export function ProxmoxDashboard() { const [showNavigation, setShowNavigation] = useState(true) const [lastScrollY, setLastScrollY] = useState(0) const [showHealthModal, setShowHealthModal] = useState(false) + const [isAuthenticated, setIsAuthenticated] = useState(false) const fetchSystemData = useCallback(async () => { const apiUrl = getApiUrl("/api/system-info") @@ -184,6 +188,18 @@ export function ProxmoxDashboard() { setIsRefreshing(false) } + const handleLogout = (e: React.MouseEvent) => { + e.stopPropagation() + localStorage.removeItem("proxmenux-auth-token") + localStorage.removeItem("proxmenux-saved-username") + localStorage.removeItem("proxmenux-saved-password") + window.location.reload() + } + + useEffect(() => { + setIsAuthenticated(!!localStorage.getItem("proxmenux-auth-token")) + }, []) + const statusIcon = useMemo(() => { switch (systemStatus.status) { case "healthy": @@ -323,6 +339,19 @@ export function ProxmoxDashboard() { Refresh + {isAuthenticated && ( + + )} +
e.stopPropagation()}>
@@ -348,6 +377,12 @@ export function ProxmoxDashboard() { + {isAuthenticated && ( + + )} +
e.stopPropagation()}>
diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 3c290ff..b031017 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -98,7 +98,7 @@ export function Settings() { const handleDisableAuth = async () => { if ( !confirm( - "Are you sure you want to disable authentication? This will remove password protection from your dashboard.", + "¿Estás seguro de que quieres deshabilitar la autenticación? Esto eliminará la protección por contraseña de tu dashboard y tendrás que configurarla de nuevo si quieres reactivarla.", ) ) { return @@ -109,19 +109,35 @@ export function Settings() { setSuccess("") try { - const response = await fetch(getApiUrl("/api/auth/setup"), { + const response = await fetch(getApiUrl("/api/auth/disable"), { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enable_auth: false }), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`, + }, }) - if (!response.ok) throw new Error("Failed to disable authentication") + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to disable authentication") + } localStorage.removeItem("proxmenux-auth-token") - setSuccess("Authentication disabled successfully!") - setAuthEnabled(false) + localStorage.removeItem("proxmenux-saved-username") + localStorage.removeItem("proxmenux-saved-password") + localStorage.removeItem("proxmenux-auth-setup-complete") + + setSuccess("¡Autenticación deshabilitada correctamente! La página se recargará...") + + // Recargar la página después de 1.5 segundos + setTimeout(() => { + window.location.reload() + }, 1500) } catch (err) { - setError("Failed to disable authentication. Please try again.") + setError( + err instanceof Error ? err.message : "No se pudo deshabilitar la autenticación. Por favor, inténtalo de nuevo.", + ) } finally { setLoading(false) } diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py index 1fbda2b..a36cdb6 100644 --- a/AppImage/scripts/auth_manager.py +++ b/AppImage/scripts/auth_manager.py @@ -26,6 +26,7 @@ AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json" JWT_SECRET = "proxmenux-monitor-secret-key-change-in-production" JWT_ALGORITHM = "HS256" TOKEN_EXPIRATION_HOURS = 24 +TOKEN_EXPIRATION_DAYS_REMEMBER = 30 # Token más largo para "recordar contraseña" def ensure_config_dir(): @@ -94,15 +95,18 @@ def verify_password(password, password_hash): return hash_password(password) == password_hash -def generate_token(username): +def generate_token(username, remember_me=False): # Añadido parámetro remember_me """Generate a JWT token for the given username""" if not JWT_AVAILABLE: return None + expiration_delta = timedelta(days=TOKEN_EXPIRATION_DAYS_REMEMBER) if remember_me else timedelta(hours=TOKEN_EXPIRATION_HOURS) + payload = { 'username': username, - 'exp': datetime.utcnow() + timedelta(hours=TOKEN_EXPIRATION_HOURS), - 'iat': datetime.utcnow() + 'exp': datetime.utcnow() + expiration_delta, + 'iat': datetime.utcnow(), + 'remember_me': remember_me # Guardar preferencia en el token } try: @@ -147,7 +151,7 @@ def get_auth_status(): config = load_auth_config() return { "auth_enabled": config.get("enabled", False), - "auth_configured": config.get("configured", False), # Frontend expects this field name + "auth_configured": config.get("configured", False), "declined": config.get("declined", False), "username": config.get("username") if config.get("enabled") else None, "authenticated": False # Will be set to True by the route handler if token is valid @@ -202,13 +206,16 @@ def disable_auth(): Disable authentication (different from decline - can be re-enabled) Returns (success: bool, message: str) """ - config = load_auth_config() - config["enabled"] = False - # Keep configured=True and don't set declined=True - # This allows re-enabling without showing the setup modal again + config = { + "enabled": False, + "username": None, + "password_hash": None, + "declined": False, + "configured": False + } if save_auth_config(config): - return True, "Authentication disabled" + return True, "Authentication disabled successfully" else: return False, "Failed to save configuration" @@ -232,7 +239,7 @@ def enable_auth(): return False, "Failed to save configuration" -def change_password(old_password, new_password): +def change_password(current_password, new_password): # Corregido nombre del parámetro """ Change the authentication password Returns (success: bool, message: str) @@ -242,7 +249,7 @@ def change_password(old_password, new_password): if not config.get("enabled"): return False, "Authentication is not enabled" - if not verify_password(old_password, config.get("password_hash", "")): + if not verify_password(current_password, config.get("password_hash", "")): return False, "Current password is incorrect" if len(new_password) < 6: @@ -256,7 +263,7 @@ def change_password(old_password, new_password): return False, "Failed to save new password" -def authenticate(username, password): +def authenticate(username, password, remember_me=False): # Añadido parámetro remember_me """ Authenticate a user with username and password Returns (success: bool, token: str or None, message: str) @@ -272,7 +279,7 @@ def authenticate(username, password): if not verify_password(password, config.get("password_hash", "")): return False, None, "Invalid username or password" - token = generate_token(username) + token = generate_token(username, remember_me) if token: return True, token, "Authentication successful" else: diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py index 1b04059..4e4fcf5 100644 --- a/AppImage/scripts/flask_auth_routes.py +++ b/AppImage/scripts/flask_auth_routes.py @@ -33,28 +33,47 @@ def auth_setup(): username = data.get('username') password = data.get('password') + if not username or not password: + return jsonify({"success": False, "error": "Username and password are required"}), 400 + success, message = auth_manager.setup_auth(username, password) if success: - return jsonify({"success": True, "message": message}) + # Generate token for immediate login + token = auth_manager.generate_token(username) + return jsonify({"success": True, "message": message, "token": token}) else: - return jsonify({"success": False, "message": message}), 400 + return jsonify({"success": False, "error": message}), 400 except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 + return jsonify({"success": False, "error": str(e)}), 500 -@auth_bp.route('/api/auth/decline', methods=['POST']) -def auth_decline(): - """Decline authentication setup""" +@auth_bp.route('/api/auth/skip', methods=['POST']) +def auth_skip(): + """Skip authentication setup (user declined)""" try: success, message = auth_manager.decline_auth() if success: return jsonify({"success": True, "message": message}) else: - return jsonify({"success": False, "message": message}), 400 + return jsonify({"success": False, "error": message}), 400 except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 + return jsonify({"success": False, "error": str(e)}), 500 + + +@auth_bp.route('/api/auth/decline', methods=['POST']) +def auth_decline(): + """Decline authentication setup (deprecated, use /api/auth/skip)""" + try: + success, message = auth_manager.decline_auth() + + if success: + return jsonify({"success": True, "message": message}) + else: + return jsonify({"success": False, "error": message}), 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 @auth_bp.route('/api/auth/login', methods=['POST']) @@ -64,15 +83,19 @@ def auth_login(): data = request.json username = data.get('username') password = data.get('password') + remember_me = data.get('remember_me', False) # Soporte para "recordar contraseña" - success, token, message = auth_manager.authenticate(username, password) + success, token, message = auth_manager.authenticate(username, password, remember_me) if success: - return jsonify({"success": True, "token": token, "message": message}) + response_data = {"success": True, "token": token, "message": message} + if remember_me: + response_data["remember_me"] = True # Indicar al frontend que guarde las credenciales + return jsonify(response_data) else: - return jsonify({"success": False, "message": message}), 401 + return jsonify({"success": False, "error": message}), 401 except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 + return jsonify({"success": False, "error": str(e)}), 500 @auth_bp.route('/api/auth/enable', methods=['POST']) @@ -84,23 +107,31 @@ def auth_enable(): if success: return jsonify({"success": True, "message": message}) else: - return jsonify({"success": False, "message": message}), 400 + return jsonify({"success": False, "error": message}), 400 except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 + return jsonify({"success": False, "error": str(e)}), 500 @auth_bp.route('/api/auth/disable', methods=['POST']) def auth_disable(): """Disable authentication""" try: + token = request.headers.get('Authorization', '').replace('Bearer ', '') + if not token: + return jsonify({"success": False, "error": "Authentication required"}), 401 + + username = auth_manager.verify_token(token) + if not username: + return jsonify({"success": False, "error": "Invalid or expired token"}), 401 + success, message = auth_manager.disable_auth() if success: return jsonify({"success": True, "message": message}) else: - return jsonify({"success": False, "message": message}), 400 + return jsonify({"success": False, "error": message}), 400 except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 + return jsonify({"success": False, "error": str(e)}), 500 @auth_bp.route('/api/auth/change-password', methods=['POST']) @@ -108,14 +139,25 @@ def auth_change_password(): """Change authentication password""" try: data = request.json - old_password = data.get('old_password') + current_password = data.get('current_password') # Corregido el nombre del campo new_password = data.get('new_password') - success, message = auth_manager.change_password(old_password, new_password) + # Verify current authentication + token = request.headers.get('Authorization', '').replace('Bearer ', '') + if not token: + return jsonify({"success": False, "error": "Authentication required"}), 401 + + username = auth_manager.verify_token(token) + if not username: + return jsonify({"success": False, "error": "Invalid or expired token"}), 401 + + success, message = auth_manager.change_password(current_password, new_password) if success: - return jsonify({"success": True, "message": message}) + # Generate new token + new_token = auth_manager.generate_token(username) + return jsonify({"success": True, "message": message, "token": new_token}) else: - return jsonify({"success": False, "message": message}), 400 + return jsonify({"success": False, "error": message}), 400 except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 0c04335..a726c5d 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -950,31 +950,37 @@ def get_pcie_link_speed(disk_name): import re match = re.match(r'(nvme\d+)n\d+', disk_name) if not match: - print(f"[v0] Could not extract controller from {disk_name}") + # print(f"[v0] Could not extract controller from {disk_name}") + pass return pcie_info controller = match.group(1) # nvme0n1 -> nvme0 - print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}") + # print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}") + pass # Path to PCIe device in sysfs sys_path = f'/sys/class/nvme/{controller}/device' - print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}") + # print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}") + pass if os.path.exists(sys_path): try: pci_address = os.path.basename(os.readlink(sys_path)) - print(f"[v0] PCI address for {disk_name}: {pci_address}") + # print(f"[v0] PCI address for {disk_name}: {pci_address}") + pass # Use lspci to get detailed PCIe information result = subprocess.run(['lspci', '-vvv', '-s', pci_address], capture_output=True, text=True, timeout=5) if result.returncode == 0: - print(f"[v0] lspci output for {pci_address}:") + # print(f"[v0] lspci output for {pci_address}:") + pass for line in result.stdout.split('\n'): # Look for "LnkSta:" line which shows current link status if 'LnkSta:' in line: - print(f"[v0] Found LnkSta: {line}") + # print(f"[v0] Found LnkSta: {line}") + pass # Example: "LnkSta: Speed 8GT/s, Width x4" if 'Speed' in line: speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line) @@ -990,17 +996,20 @@ def get_pcie_link_speed(disk_name): pcie_info['pcie_gen'] = '4.0' else: pcie_info['pcie_gen'] = '5.0' - print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}") + # print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}") + pass if 'Width' in line: width_match = re.search(r'Width\s+x(\d+)', line) if width_match: pcie_info['pcie_width'] = f'x{width_match.group(1)}' - print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}") + # print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}") + pass # Look for "LnkCap:" line which shows maximum capabilities elif 'LnkCap:' in line: - print(f"[v0] Found LnkCap: {line}") + # print(f"[v0] Found LnkCap: {line}") + pass if 'Speed' in line: speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line) if speed_match: @@ -1015,39 +1024,48 @@ def get_pcie_link_speed(disk_name): pcie_info['pcie_max_gen'] = '4.0' else: pcie_info['pcie_max_gen'] = '5.0' - print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}") + # print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}") + pass if 'Width' in line: width_match = re.search(r'Width\s+x(\d+)', line) if width_match: pcie_info['pcie_max_width'] = f'x{width_match.group(1)}' - print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}") + # print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}") + pass else: - print(f"[v0] lspci failed with return code: {result.returncode}") + # print(f"[v0] lspci failed with return code: {result.returncode}") + pass except Exception as e: - print(f"[v0] Error getting PCIe info via lspci: {e}") + # print(f"[v0] Error getting PCIe info via lspci: {e}") + pass import traceback traceback.print_exc() else: - print(f"[v0] sys_path does not exist: {sys_path}") + # print(f"[v0] sys_path does not exist: {sys_path}") + pass alt_sys_path = f'/sys/block/{disk_name}/device/device' - print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}") + # print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}") + pass if os.path.exists(alt_sys_path): try: # Get PCI address from the alternative path pci_address = os.path.basename(os.readlink(alt_sys_path)) - print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}") + # print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}") + pass # Use lspci to get detailed PCIe information result = subprocess.run(['lspci', '-vvv', '-s', pci_address], capture_output=True, text=True, timeout=5) if result.returncode == 0: - print(f"[v0] lspci output for {pci_address} (from alt path):") + # print(f"[v0] lspci output for {pci_address} (from alt path):") + pass for line in result.stdout.split('\n'): # Look for "LnkSta:" line which shows current link status if 'LnkSta:' in line: - print(f"[v0] Found LnkSta: {line}") + # print(f"[v0] Found LnkSta: {line}") + pass if 'Speed' in line: speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line) if speed_match: @@ -1062,17 +1080,20 @@ def get_pcie_link_speed(disk_name): pcie_info['pcie_gen'] = '4.0' else: pcie_info['pcie_gen'] = '5.0' - print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}") + # print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}") + pass if 'Width' in line: width_match = re.search(r'Width\s+x(\d+)', line) if width_match: pcie_info['pcie_width'] = f'x{width_match.group(1)}' - print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}") + # print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}") + pass # Look for "LnkCap:" line which shows maximum capabilities elif 'LnkCap:' in line: - print(f"[v0] Found LnkCap: {line}") + # print(f"[v0] Found LnkCap: {line}") + pass if 'Speed' in line: speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line) if speed_match: @@ -1087,26 +1108,32 @@ def get_pcie_link_speed(disk_name): pcie_info['pcie_max_gen'] = '4.0' else: pcie_info['pcie_max_gen'] = '5.0' - print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}") + # print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}") + pass if 'Width' in line: width_match = re.search(r'Width\s+x(\d+)', line) if width_match: pcie_info['pcie_max_width'] = f'x{width_match.group(1)}' - print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}") + # print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}") + pass else: - print(f"[v0] lspci failed with return code: {result.returncode}") + # print(f"[v0] lspci failed with return code: {result.returncode}") + pass except Exception as e: - print(f"[v0] Error getting PCIe info from alt path: {e}") + # print(f"[v0] Error getting PCIe info from alt path: {e}") + pass import traceback traceback.print_exc() except Exception as e: - print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}") + # print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}") + pass import traceback traceback.print_exc() - print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}") + # print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}") + pass return pcie_info # get_pcie_link_speed function definition ends here