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 && ( +
+
+ Loading tokens... +
+ )} + + {!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