Update AppImage

This commit is contained in:
MacRimi
2025-11-04 09:14:29 +01:00
parent 11e3f53a2f
commit c0ec74fb12
6 changed files with 561 additions and 1 deletions

View File

@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
{step === "choice" ? (
<div className="space-y-6">
<div className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
<p className="text-muted-foreground">
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
</p>
</div>
<div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" />
Yes, Setup Password
</Button>
<Button
onClick={handleSkipAuth}
variant="outline"
className="w-full bg-transparent"
size="lg"
disabled={loading}
>
No, Continue Without Protection
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
</div>
) : (
<div className="space-y-6">
<div className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Lock className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Setup Authentication</h2>
<p className="text-muted-foreground">Create a username and password to protect your dashboard</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="Enter username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Setting up..." : "Setup Authentication"}
</Button>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
Back
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -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 (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="w-20 h-20 relative flex items-center justify-center bg-primary/10 rounded-lg">
<Image
src="/images/proxmenux-logo.png"
alt="ProxMenux Logo"
width={80}
height={80}
className="object-contain"
priority
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
const fallback = target.parentElement?.querySelector(".fallback-icon")
if (fallback) {
fallback.classList.remove("hidden")
}
}}
/>
<Server className="h-12 w-12 text-primary absolute fallback-icon hidden" />
</div>
</div>
<div>
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
</div>
</div>
<div className="bg-card border border-border rounded-lg p-6 shadow-lg">
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="login-username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10"
disabled={loading}
autoComplete="username"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
disabled={loading}
autoComplete="current-password"
/>
</div>
</div>
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
</div>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.0</p>
</div>
</div>
)
}

View File

@@ -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<typeof setTimeout>
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 (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
)
}
if (authRequired && !isAuthenticated) {
return <Login onLogin={handleLoginSuccess} />
}
return (
<div className="min-h-screen bg-background">
<OnboardingCarousel />
{!authSetupComplete && <AuthSetup onComplete={handleAuthSetupComplete} />}
{!isServerConnected && (
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
<div className="container mx-auto">

View File

@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
className,
)}
ref={ref}

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -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