diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx new file mode 100644 index 0000000..a6ff242 --- /dev/null +++ b/AppImage/components/auth-setup.tsx @@ -0,0 +1,221 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "./ui/button" +import { Dialog, DialogContent } from "./ui/dialog" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { Shield, Lock, User, AlertCircle } from "lucide-react" +import { getApiUrl } from "../lib/api-config" + +interface AuthSetupProps { + onComplete: () => void +} + +export function AuthSetup({ onComplete }: AuthSetupProps) { + const [open, setOpen] = useState(false) + const [step, setStep] = useState<"choice" | "setup">("choice") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + + useEffect(() => { + // Check if onboarding is complete and auth setup is needed + const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen") + const authSetupComplete = localStorage.getItem("proxmenux-auth-setup-complete") + + if (hasSeenOnboarding && !authSetupComplete) { + // Small delay to show after onboarding closes + setTimeout(() => setOpen(true), 500) + } + }, []) + + const handleSkipAuth = async () => { + setLoading(true) + setError("") + + try { + const response = await fetch(getApiUrl("/api/auth/setup"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enable_auth: false }), + }) + + if (!response.ok) throw new Error("Failed to save preference") + + localStorage.setItem("proxmenux-auth-setup-complete", "true") + setOpen(false) + onComplete() + } catch (err) { + setError("Failed to save preference. Please try again.") + } finally { + setLoading(false) + } + } + + const handleSetupAuth = async () => { + setError("") + + if (!username || !password) { + setError("Please fill in all fields") + return + } + + if (password !== confirmPassword) { + setError("Passwords do not match") + return + } + + if (password.length < 6) { + setError("Password must be at least 6 characters") + return + } + + setLoading(true) + + try { + const response = await fetch(getApiUrl("/api/auth/setup"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username, + password, + enable_auth: true, + }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to setup authentication") + } + + // Save token + localStorage.setItem("proxmenux-auth-token", data.token) + localStorage.setItem("proxmenux-auth-setup-complete", "true") + + setOpen(false) + onComplete() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to setup authentication") + } finally { + setLoading(false) + } + } + + return ( + + + {step === "choice" ? ( +
+
+
+ +
+

Protect Your Dashboard?

+

+ Add an extra layer of security to protect your Proxmox data when accessing from non-private networks. +

+
+ +
+ + +
+ +

You can always enable this later in Settings

+
+ ) : ( +
+
+
+ +
+

Setup Authentication

+

Create a username and password to protect your dashboard

+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+
+ +
+ + setUsername(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+
+ +
+ + +
+
+ )} +
+
+ ) +} diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx new file mode 100644 index 0000000..3f8ca87 --- /dev/null +++ b/AppImage/components/login.tsx @@ -0,0 +1,141 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { Lock, User, AlertCircle, Server } from "lucide-react" +import { getApiUrl } from "../lib/api-config" +import Image from "next/image" + +interface LoginProps { + onLogin: () => void +} + +export function Login({ onLogin }: LoginProps) { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (!username || !password) { + setError("Please enter username and password") + return + } + + setLoading(true) + + try { + const response = await fetch(getApiUrl("/api/auth/login"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Login failed") + } + + // Save token + localStorage.setItem("proxmenux-auth-token", data.token) + onLogin() + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed") + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+
+ ProxMenux Logo { + const target = e.target as HTMLImageElement + target.style.display = "none" + const fallback = target.parentElement?.querySelector(".fallback-icon") + if (fallback) { + fallback.classList.remove("hidden") + } + }} + /> + +
+
+
+

ProxMenux Monitor

+

Sign in to access your dashboard

+
+
+ +
+
+ {error && ( +
+ +

{error}

+
+ )} + +
+ +
+ + setUsername(e.target.value)} + className="pl-10" + disabled={loading} + autoComplete="username" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + disabled={loading} + autoComplete="current-password" + /> +
+
+ + +
+
+ +

ProxMenux Monitor v1.0.0

+
+
+ ) +} diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index da744ba..27d2a4b 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -11,6 +11,8 @@ import { VirtualMachines } from "./virtual-machines" import Hardware from "./hardware" import { SystemLogs } from "./system-logs" import { OnboardingCarousel } from "./onboarding-carousel" +import { AuthSetup } from "./auth-setup" +import { Login } from "./login" import { getApiUrl } from "../lib/api-config" import { RefreshCw, @@ -63,6 +65,10 @@ export function ProxmoxDashboard() { const [activeTab, setActiveTab] = useState("overview") const [showNavigation, setShowNavigation] = useState(true) const [lastScrollY, setLastScrollY] = useState(0) + const [authChecked, setAuthChecked] = useState(false) + const [authRequired, setAuthRequired] = useState(false) + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [authSetupComplete, setAuthSetupComplete] = useState(false) const fetchSystemData = useCallback(async () => { console.log("[v0] Fetching system data from Flask server...") @@ -219,10 +225,119 @@ export function ProxmoxDashboard() { } } + const setupTokenRefresh = () => { + let refreshTimeout: ReturnType + + const refreshToken = async () => { + const token = localStorage.getItem("proxmenux-auth-token") + if (!token) return + + try { + const response = await fetch(getApiUrl("/api/auth/refresh"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }) + + if (response.ok) { + const data = await response.json() + localStorage.setItem("proxmenux-auth-token", data.token) + console.log("[v0] Token refreshed successfully") + } + } catch (error) { + console.error("[v0] Failed to refresh token:", error) + } + } + + const resetRefreshTimer = () => { + clearTimeout(refreshTimeout) + // Refresh token every 25 minutes (before 30 min expiry) + refreshTimeout = setTimeout(refreshToken, 25 * 60 * 1000) + } + + // Refresh on user activity + const events = ["mousedown", "keydown", "scroll", "touchstart"] + events.forEach((event) => { + window.addEventListener(event, resetRefreshTimer, { passive: true }) + }) + + resetRefreshTimer() + + return () => { + clearTimeout(refreshTimeout) + events.forEach((event) => { + window.removeEventListener(event, resetRefreshTimer) + }) + } + } + + const handleAuthSetupComplete = () => { + setAuthSetupComplete(true) + setIsAuthenticated(true) + } + + const handleLoginSuccess = () => { + setIsAuthenticated(true) + setupTokenRefresh() + } + + useEffect(() => { + const checkAuth = async () => { + try { + const token = localStorage.getItem("proxmenux-auth-token") + const headers: HeadersInit = { "Content-Type": "application/json" } + + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + + const response = await fetch(getApiUrl("/api/auth/status"), { + headers, + }) + + const data = await response.json() + + setAuthRequired(data.auth_enabled) + setIsAuthenticated(data.authenticated) + setAuthSetupComplete(localStorage.getItem("proxmenux-auth-setup-complete") === "true") + setAuthChecked(true) + + // Setup token refresh if authenticated + if (data.authenticated && token) { + setupTokenRefresh() + } + } catch (error) { + console.error("[v0] Failed to check auth status:", error) + setAuthChecked(true) + } + } + + checkAuth() + }, []) + + if (!authChecked) { + return ( +
+
+
+

Loading...

+
+
+ ) + } + + if (authRequired && !isAuthenticated) { + return + } + return (
+ {!authSetupComplete && } + {!isServerConnected && (
diff --git a/AppImage/components/ui/input.tsx b/AppImage/components/ui/input.tsx index 31bbca4..d32a72e 100644 --- a/AppImage/components/ui/input.tsx +++ b/AppImage/components/ui/input.tsx @@ -9,7 +9,7 @@ const Input = React.forwardRef(({ className, type, , + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 783c611..2090fb4 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -22,6 +22,72 @@ import xml.etree.ElementTree as ET # Added for XML parsing import math # Imported math for format_bytes function import urllib.parse # Added for URL encoding import platform # Added for platform.release() +import hashlib +import secrets +import jwt +from functools import wraps +from pathlib import Path + +# Authentication configuration +AUTH_CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor" +AUTH_CONFIG_FILE = AUTH_CONFIG_DIR / "auth.json" +JWT_SECRET = secrets.token_hex(32) # Generate a random secret for JWT +SESSION_TIMEOUT = 30 * 60 # 30 minutes in seconds + +# Ensure config directory exists +AUTH_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + +def hash_password(password: str) -> str: + """Hash a password using SHA-256""" + return hashlib.sha256(password.encode()).hexdigest() + +def load_auth_config(): + """Load authentication configuration from file""" + if not AUTH_CONFIG_FILE.exists(): + return {"auth_enabled": False} + + try: + with open(AUTH_CONFIG_FILE, 'r') as f: + return json.load(f) + except: + return {"auth_enabled": False} + +def save_auth_config(config): + """Save authentication configuration to file""" + with open(AUTH_CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + +def require_auth(f): + """Decorator to require authentication for endpoints""" + @wraps(f) + def decorated_function(*args, **kwargs): + auth_config = load_auth_config() + + # If auth is not enabled, allow access + if not auth_config.get("auth_enabled", False): + return f(*args, **kwargs) + + # Check for Authorization header + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authentication required"}), 401 + + token = auth_header.split(' ')[1] + + try: + # Verify JWT token + payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) + + # Check if token is expired + if time.time() > payload.get('exp', 0): + return jsonify({"error": "Token expired"}), 401 + + return f(*args, **kwargs) + except jwt.InvalidTokenError: + return jsonify({"error": "Invalid token"}), 401 + + return decorated_function + app = Flask(__name__) CORS(app) # Enable CORS for Next.js frontend