Update AppImage

This commit is contained in:
MacRimi
2025-11-07 20:36:46 +01:00
parent f2f9c37ee2
commit 5dd8b3ee36
5 changed files with 857 additions and 59 deletions

View File

@@ -2,11 +2,12 @@
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 { Lock, User, AlertCircle, Server } from "lucide-react"
import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import Image from "next/image"
@@ -17,9 +18,23 @@ interface LoginProps {
export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("")
const [requiresTotp, setRequiresTotp] = useState(false)
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)
}
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
@@ -29,23 +44,46 @@ export function Login({ onLogin }: LoginProps) {
return
}
if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code")
return
}
setLoading(true)
try {
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,
totp_token: totpCode || undefined, // Include 2FA code if provided
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Login failed")
if (data.requires_totp) {
setRequiresTotp(true)
setLoading(false)
return
}
if (!response.ok) {
throw new Error(data.message || "Login failed")
}
// Save token
localStorage.setItem("proxmenux-auth-token", data.token)
if (rememberMe) {
localStorage.setItem("proxmenux-saved-username", username)
localStorage.setItem("proxmenux-saved-password", password)
} else {
localStorage.removeItem("proxmenux-saved-username")
localStorage.removeItem("proxmenux-saved-password")
}
onLogin()
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed")
@@ -94,46 +132,109 @@ export function Login({ onLogin }: LoginProps) {
</div>
)}
<div className="space-y-2">
<Label htmlFor="login-username" className="text-sm">
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 text-base"
disabled={loading}
autoComplete="username"
/>
</div>
</div>
{!requiresTotp ? (
<>
<div className="space-y-2">
<Label htmlFor="login-username" className="text-sm">
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 text-base"
disabled={loading}
autoComplete="username"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="login-password" className="text-sm">
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 text-base"
disabled={loading}
autoComplete="current-password"
/>
<div className="space-y-2">
<Label htmlFor="login-password" className="text-sm">
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 text-base"
disabled={loading}
autoComplete="current-password"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
disabled={loading}
/>
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
Remember me
</Label>
</div>
</>
) : (
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-500">Autenticación de Dos Factores</p>
<p className="text-xs text-blue-500 mt-1">
Introduce el código de 6 dígitos de tu aplicación de autenticación
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm">
Código de Autenticación
</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="text-center text-lg tracking-widest font-mono text-base"
maxLength={6}
disabled={loading}
autoComplete="one-time-code"
autoFocus
/>
<p className="text-xs text-muted-foreground text-center">
También puedes usar un código de respaldo (formato: XXXX-XXXX)
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setRequiresTotp(false)
setTotpCode("")
setError("")
}}
className="w-full"
>
Volver al inicio de sesión
</Button>
</div>
</div>
)}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
</Button>
</form>
</div>

View File

@@ -7,9 +7,11 @@ import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup"
export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
@@ -26,6 +28,10 @@ export function Settings() {
const [newPassword, setNewPassword] = useState("")
const [confirmNewPassword, setConfirmNewPassword] = useState("")
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
const [disable2FAPassword, setDisable2FAPassword] = useState("")
useEffect(() => {
checkAuthStatus()
}, [])
@@ -35,6 +41,7 @@ export function Settings() {
const response = await fetch(getApiUrl("/api/auth/status"))
const data = await response.json()
setAuthEnabled(data.auth_enabled || false)
setTotpEnabled(data.totp_enabled || false) // Get 2FA status
} catch (err) {
console.error("Failed to check auth status:", err)
}
@@ -196,6 +203,46 @@ export function Settings() {
}
}
const handleDisable2FA = async () => {
setError("")
setSuccess("")
if (!disable2FAPassword) {
setError("Please enter your password")
return
}
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/disable"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ password: disable2FAPassword }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to disable 2FA")
}
setSuccess("2FA disabled successfully!")
setTotpEnabled(false)
setShow2FADisable(false)
setDisable2FAPassword("")
checkAuthStatus()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to disable 2FA")
} finally {
setLoading(false)
}
}
const handleLogout = () => {
localStorage.removeItem("proxmenux-auth-token")
localStorage.removeItem("proxmenux-auth-setup-complete")
@@ -418,6 +465,74 @@ export function Settings() {
</div>
)}
{!totpEnabled && (
<Button
onClick={() => setShow2FASetup(true)}
variant="outline"
className="w-full bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/20"
>
<Shield className="h-4 w-4 mr-2" />
Habilitar Autenticación de Dos Factores
</Button>
)}
{totpEnabled && (
<div className="space-y-3">
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<p className="text-sm text-green-500 font-medium">2FA está activado</p>
</div>
{!show2FADisable && (
<Button onClick={() => setShow2FADisable(true)} variant="outline" className="w-full">
<Shield className="h-4 w-4 mr-2" />
Desactivar 2FA
</Button>
)}
{show2FADisable && (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold">Desactivar Autenticación de Dos Factores</h3>
<p className="text-sm text-muted-foreground">Introduce tu contraseña para confirmar</p>
<div className="space-y-2">
<Label htmlFor="disable-2fa-password">Contraseña</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="disable-2fa-password"
type="password"
placeholder="Introduce tu contraseña"
value={disable2FAPassword}
onChange={(e) => setDisable2FAPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleDisable2FA} variant="destructive" className="flex-1" disabled={loading}>
{loading ? "Desactivando..." : "Desactivar 2FA"}
</Button>
<Button
onClick={() => {
setShow2FADisable(false)
setDisable2FAPassword("")
setError("")
}}
variant="outline"
className="flex-1"
disabled={loading}
>
Cancelar
</Button>
</div>
</div>
)}
</div>
)}
<Button onClick={handleDisableAuth} variant="destructive" className="w-full" disabled={loading}>
Disable Authentication
</Button>
@@ -443,6 +558,15 @@ export function Settings() {
</div>
</CardContent>
</Card>
<TwoFactorSetup
open={show2FASetup}
onClose={() => setShow2FASetup(false)}
onSuccess={() => {
setSuccess("2FA habilitado correctamente!")
checkAuthStatus()
}}
/>
</div>
)
}

View File

@@ -0,0 +1,266 @@
"use client"
import { useState } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import Image from "next/image"
interface TwoFactorSetupProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
const [step, setStep] = useState(1)
const [qrCode, setQrCode] = useState("")
const [secret, setSecret] = useState("")
const [backupCodes, setBackupCodes] = useState<string[]>([])
const [verificationCode, setVerificationCode] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const [copiedSecret, setCopiedSecret] = useState(false)
const [copiedCodes, setCopiedCodes] = useState(false)
const handleSetupStart = async () => {
setError("")
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/setup"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to setup 2FA")
}
setQrCode(data.qr_code)
setSecret(data.secret)
setBackupCodes(data.backup_codes)
setStep(2)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
} finally {
setLoading(false)
}
}
const handleVerify = async () => {
if (!verificationCode || verificationCode.length !== 6) {
setError("Please enter a 6-digit code")
return
}
setError("")
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/enable"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ token: verificationCode }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Invalid verification code")
}
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed")
} finally {
setLoading(false)
}
}
const copyToClipboard = (text: string, type: "secret" | "codes") => {
navigator.clipboard.writeText(text)
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
}
}
const handleClose = () => {
setStep(1)
setQrCode("")
setSecret("")
setBackupCodes([])
setVerificationCode("")
setError("")
onClose()
}
const handleFinish = () => {
handleClose()
onSuccess()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
Configurar Autenticación de Dos Factores
</DialogTitle>
<DialogDescription>Añade una capa extra de seguridad a tu cuenta</DialogDescription>
</DialogHeader>
{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>
)}
{step === 1 && (
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-sm text-blue-500">
La autenticación de dos factores (2FA) añade una capa extra de seguridad requiriendo un código de tu
aplicación de autenticación además de tu contraseña.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium">Necesitarás:</h4>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>Una aplicación de autenticación (Google Authenticator, Authy, etc.)</li>
<li>Escanear un código QR o introducir una clave manualmente</li>
<li>Guardar códigos de respaldo de forma segura</li>
</ul>
</div>
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Iniciando..." : "Comenzar Configuración"}
</Button>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">1. Escanea el código QR</h4>
<p className="text-sm text-muted-foreground">
Abre tu aplicación de autenticación y escanea este código QR
</p>
{qrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg">
<Image src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} />
</div>
)}
</div>
<div className="space-y-2">
<h4 className="font-medium">O introduce la clave manualmente:</h4>
<div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-sm" />
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(secret, "secret")}
title="Copiar clave"
>
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium">2. Introduce el código de verificación</h4>
<p className="text-sm text-muted-foreground">
Introduce el código de 6 dígitos que aparece en tu aplicación
</p>
<Input
type="text"
placeholder="000000"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="text-center text-lg tracking-widest font-mono"
maxLength={6}
disabled={loading}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Verificando..." : "Verificar y Activar"}
</Button>
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
Cancelar
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-500">2FA Activado Correctamente</p>
<p className="text-sm text-green-500 mt-1">
Tu cuenta ahora está protegida con autenticación de dos factores
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-orange-500">Importante: Guarda tus códigos de respaldo</h4>
<p className="text-sm text-muted-foreground">
Estos códigos te permitirán acceder a tu cuenta si pierdes acceso a tu aplicación de autenticación.
Guárdalos en un lugar seguro.
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Códigos de Respaldo</span>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
{copiedCodes ? (
<Check className="h-4 w-4 text-green-500 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copiar Todos
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<div key={index} className="bg-background rounded px-3 py-2 font-mono text-sm text-center">
{code}
</div>
))}
</div>
</div>
</div>
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
Finalizar
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}