From 108a169e7cc648752d41d0265bc46179408a2366 Mon Sep 17 00:00:00 2001
From: MacRimi
Date: Sat, 7 Feb 2026 18:03:46 +0100
Subject: [PATCH] Update 2FA
---
AppImage/components/settings.tsx | 187 ++++++++++++++++++++++-
AppImage/components/two-factor-setup.tsx | 36 ++++-
AppImage/scripts/auth_manager.py | 105 ++++++++++++-
AppImage/scripts/flask_auth_routes.py | 35 +++++
4 files changed, 346 insertions(+), 17 deletions(-)
diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx
index f5cc39b1..5721c4a7 100644
--- a/AppImage/components/settings.tsx
+++ b/AppImage/components/settings.tsx
@@ -5,7 +5,7 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
-import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler } from 'lucide-react'
+import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler, Trash2, RefreshCw, Clock } from 'lucide-react'
import { APP_VERSION } from "./release-notes-modal"
import { getApiUrl, fetchApi } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup"
@@ -18,6 +18,15 @@ interface ProxMenuxTool {
enabled: boolean
}
+interface ApiTokenEntry {
+ id: string
+ name: string
+ token_prefix: string
+ created_at: string
+ expires_at: string
+ revoked: boolean
+}
+
export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false)
@@ -56,13 +65,20 @@ export function Settings() {
const [generatingToken, setGeneratingToken] = useState(false)
const [tokenCopied, setTokenCopied] = useState(false)
+ // Token list state
+ const [existingTokens, setExistingTokens] = useState([])
+ const [loadingTokens, setLoadingTokens] = useState(false)
+ const [revokingTokenId, setRevokingTokenId] = useState(null)
+ const [tokenName, setTokenName] = useState("API Token")
+
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
useEffect(() => {
checkAuthStatus()
loadProxmenuxTools()
- getUnitsSettings() // Load units settings on mount
+ loadApiTokens()
+ getUnitsSettings()
}, [])
const checkAuthStatus = async () => {
@@ -293,6 +309,47 @@ export function Settings() {
window.location.reload()
}
+ const loadApiTokens = async () => {
+ try {
+ setLoadingTokens(true)
+ const data = await fetchApi("/api/auth/api-tokens")
+ if (data.success) {
+ setExistingTokens(data.tokens || [])
+ }
+ } catch {
+ // Silently fail - tokens section is optional
+ } finally {
+ setLoadingTokens(false)
+ }
+ }
+
+ const handleRevokeToken = async (tokenId: string) => {
+ if (!confirm("Are you sure you want to revoke this token? Any integration using it will stop working immediately.")) {
+ return
+ }
+
+ setRevokingTokenId(tokenId)
+ setError("")
+ setSuccess("")
+
+ try {
+ const data = await fetchApi(`/api/auth/api-tokens/${tokenId}`, {
+ method: "DELETE",
+ })
+
+ if (data.success) {
+ setSuccess("Token revoked successfully")
+ setExistingTokens((prev) => prev.filter((t) => t.id !== tokenId))
+ } else {
+ setError(data.message || "Failed to revoke token")
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to revoke token")
+ } finally {
+ setRevokingTokenId(null)
+ }
+ }
+
const handleGenerateApiToken = async () => {
setError("")
setSuccess("")
@@ -316,6 +373,7 @@ export function Settings() {
body: JSON.stringify({
password: tokenPassword,
totp_token: totpEnabled ? tokenTotpCode : undefined,
+ token_name: tokenName || "API Token",
}),
})
@@ -333,6 +391,8 @@ export function Settings() {
setSuccess("API token generated successfully! Make sure to copy it now as you won't be able to see it again.")
setTokenPassword("")
setTokenTotpCode("")
+ setTokenName("API Token")
+ loadApiTokens()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate API token. Please try again.")
} finally {
@@ -340,10 +400,36 @@ export function Settings() {
}
}
- const copyApiToken = () => {
- navigator.clipboard.writeText(apiToken)
- setTokenCopied(true)
- setTimeout(() => setTokenCopied(false), 2000)
+ const copyToClipboard = async (text: string) => {
+ try {
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
+ await navigator.clipboard.writeText(text)
+ } else {
+ // Fallback for non-secure contexts (HTTP on local network)
+ const textarea = document.createElement("textarea")
+ textarea.value = text
+ textarea.style.position = "fixed"
+ textarea.style.left = "-9999px"
+ textarea.style.top = "-9999px"
+ textarea.style.opacity = "0"
+ document.body.appendChild(textarea)
+ textarea.focus()
+ textarea.select()
+ document.execCommand("copy")
+ document.body.removeChild(textarea)
+ }
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ const copyApiToken = async () => {
+ const ok = await copyToClipboard(apiToken)
+ if (ok) {
+ setTokenCopied(true)
+ setTimeout(() => setTokenCopied(false), 2000)
+ }
}
const toggleVersion = (version: string) => {
@@ -763,6 +849,22 @@ export function Settings() {
Enter your credentials to generate a new long-lived API token
+
+
+
+
+ setTokenName(e.target.value)}
+ className="pl-10"
+ disabled={generatingToken}
+ />
+
+
+
@@ -811,6 +913,7 @@ export function Settings() {
setShowApiTokenSection(false)
setTokenPassword("")
setTokenTotpCode("")
+ setTokenName("API Token")
setError("")
}}
variant="outline"
@@ -896,6 +999,78 @@ export function Settings() {
)}
+
+ {/* Existing Tokens List */}
+ {!loadingTokens && existingTokens.length > 0 && (
+
+
+
Active Tokens
+
+
+
+
+ {existingTokens.map((token) => (
+
+
+
+
+
+
+
{token.name}
+
+ {token.token_prefix}
+
+
+ {token.created_at
+ ? new Date(token.created_at).toLocaleDateString()
+ : "Unknown"}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {loadingTokens && (
+
+ )}
+
+ {!loadingTokens && existingTokens.length === 0 && !showApiTokenSection && !apiToken && (
+
+ No API tokens created yet
+
+ )}
)}
diff --git a/AppImage/components/two-factor-setup.tsx b/AppImage/components/two-factor-setup.tsx
index 9a4a9cb1..951f92cf 100644
--- a/AppImage/components/two-factor-setup.tsx
+++ b/AppImage/components/two-factor-setup.tsx
@@ -89,14 +89,34 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
}
}
- 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 copyToClipboard = async (text: string, type: "secret" | "codes") => {
+ try {
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
+ await navigator.clipboard.writeText(text)
+ } else {
+ // Fallback for non-secure contexts (HTTP)
+ const textarea = document.createElement("textarea")
+ textarea.value = text
+ textarea.style.position = "fixed"
+ textarea.style.left = "-9999px"
+ textarea.style.top = "-9999px"
+ textarea.style.opacity = "0"
+ document.body.appendChild(textarea)
+ textarea.focus()
+ textarea.select()
+ document.execCommand("copy")
+ document.body.removeChild(textarea)
+ }
+
+ if (type === "secret") {
+ setCopiedSecret(true)
+ setTimeout(() => setCopiedSecret(false), 2000)
+ } else {
+ setCopiedCodes(true)
+ setTimeout(() => setCopiedCodes(false), 2000)
+ }
+ } catch {
+ console.error("Failed to copy to clipboard")
}
}
diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py
index 7c45dee4..c60163f5 100644
--- a/AppImage/scripts/auth_manager.py
+++ b/AppImage/scripts/auth_manager.py
@@ -57,7 +57,9 @@ def load_auth_config():
"configured": bool,
"totp_enabled": bool, # 2FA enabled flag
"totp_secret": str, # TOTP secret key
- "backup_codes": list # List of backup codes
+ "backup_codes": list, # List of backup codes
+ "api_tokens": list, # List of stored API token metadata
+ "revoked_tokens": list # List of revoked token hashes
}
"""
if not AUTH_CONFIG_FILE.exists():
@@ -69,7 +71,9 @@ def load_auth_config():
"configured": False,
"totp_enabled": False,
"totp_secret": None,
- "backup_codes": []
+ "backup_codes": [],
+ "api_tokens": [],
+ "revoked_tokens": []
}
try:
@@ -81,6 +85,8 @@ def load_auth_config():
config.setdefault("totp_enabled", False)
config.setdefault("totp_secret", None)
config.setdefault("backup_codes", [])
+ config.setdefault("api_tokens", [])
+ config.setdefault("revoked_tokens", [])
return config
except Exception as e:
print(f"Error loading auth config: {e}")
@@ -92,7 +98,9 @@ def load_auth_config():
"configured": False,
"totp_enabled": False,
"totp_secret": None,
- "backup_codes": []
+ "backup_codes": [],
+ "api_tokens": [],
+ "revoked_tokens": []
}
@@ -141,11 +149,18 @@ def verify_token(token):
"""
Verify a JWT token
Returns username if valid, None otherwise
+ Also checks if the token has been revoked
"""
if not JWT_AVAILABLE or not token:
return None
try:
+ # Check if the token has been revoked
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ config = load_auth_config()
+ if token_hash in config.get("revoked_tokens", []):
+ return None
+
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('username')
except jwt.ExpiredSignatureError:
@@ -156,6 +171,88 @@ def verify_token(token):
return None
+def store_api_token_metadata(token, token_name="API Token"):
+ """
+ Store API token metadata (hash, name, creation date) for listing and revocation.
+ The actual token is never stored - only a hash for identification.
+ """
+ config = load_auth_config()
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ token_id = token_hash[:16]
+
+ token_entry = {
+ "id": token_id,
+ "name": token_name,
+ "token_hash": token_hash,
+ "token_prefix": token[:12] + "...",
+ "created_at": datetime.utcnow().isoformat() + "Z",
+ "expires_at": (datetime.utcnow() + timedelta(days=365)).isoformat() + "Z"
+ }
+
+ config.setdefault("api_tokens", [])
+ config["api_tokens"].append(token_entry)
+ save_auth_config(config)
+ return token_entry
+
+
+def list_api_tokens():
+ """
+ List all stored API token metadata (no actual tokens are returned).
+ Returns list of token entries with id, name, prefix, creation and expiration dates.
+ """
+ config = load_auth_config()
+ tokens = config.get("api_tokens", [])
+ revoked = set(config.get("revoked_tokens", []))
+
+ result = []
+ for t in tokens:
+ entry = {
+ "id": t.get("id"),
+ "name": t.get("name", "API Token"),
+ "token_prefix": t.get("token_prefix", "***"),
+ "created_at": t.get("created_at"),
+ "expires_at": t.get("expires_at"),
+ "revoked": t.get("token_hash") in revoked
+ }
+ result.append(entry)
+ return result
+
+
+def revoke_api_token(token_id):
+ """
+ Revoke an API token by its ID.
+ Adds the token hash to the revoked list so it fails verification.
+ Returns (success: bool, message: str)
+ """
+ config = load_auth_config()
+ tokens = config.get("api_tokens", [])
+
+ target = None
+ for t in tokens:
+ if t.get("id") == token_id:
+ target = t
+ break
+
+ if not target:
+ return False, "Token not found"
+
+ token_hash = target.get("token_hash")
+ config.setdefault("revoked_tokens", [])
+
+ if token_hash in config["revoked_tokens"]:
+ return False, "Token is already revoked"
+
+ config["revoked_tokens"].append(token_hash)
+
+ # Remove from the active tokens list
+ config["api_tokens"] = [t for t in tokens if t.get("id") != token_id]
+
+ if save_auth_config(config):
+ return True, "Token revoked successfully"
+ else:
+ return False, "Failed to save configuration"
+
+
def get_auth_status():
"""
Get current authentication status
@@ -243,6 +340,8 @@ def disable_auth():
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
+ config["api_tokens"] = []
+ config["revoked_tokens"] = []
if save_auth_config(config):
return True, "Authentication disabled"
diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py
index d5b5e8da..ab5a6b47 100644
--- a/AppImage/scripts/flask_auth_routes.py
+++ b/AppImage/scripts/flask_auth_routes.py
@@ -262,6 +262,9 @@ def generate_api_token():
'iat': datetime.datetime.utcnow()
}, auth_manager.JWT_SECRET, algorithm='HS256')
+ # Store token metadata for listing and revocation
+ auth_manager.store_api_token_metadata(api_token, token_name)
+
return jsonify({
"success": True,
"token": api_token,
@@ -276,3 +279,35 @@ def generate_api_token():
except Exception as e:
print(f"[ERROR] generate_api_token: {str(e)}") # Log error for debugging
return jsonify({"success": False, "message": f"Internal error: {str(e)}"}), 500
+
+
+@auth_bp.route('/api/auth/api-tokens', methods=['GET'])
+def list_api_tokens():
+ """List all generated API tokens (metadata only, no actual token values)"""
+ try:
+ token = request.headers.get('Authorization', '').replace('Bearer ', '')
+ if not token or not auth_manager.verify_token(token):
+ return jsonify({"success": False, "message": "Unauthorized"}), 401
+
+ tokens = auth_manager.list_api_tokens()
+ return jsonify({"success": True, "tokens": tokens})
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500
+
+
+@auth_bp.route('/api/auth/api-tokens/
', methods=['DELETE'])
+def revoke_api_token_route(token_id):
+ """Revoke an API token by its ID"""
+ try:
+ token = request.headers.get('Authorization', '').replace('Bearer ', '')
+ if not token or not auth_manager.verify_token(token):
+ return jsonify({"success": False, "message": "Unauthorized"}), 401
+
+ success, message = auth_manager.revoke_api_token(token_id)
+
+ if success:
+ return jsonify({"success": True, "message": message})
+ else:
+ return jsonify({"success": False, "message": message}), 400
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500