From b09160274173d97a2f608535edbb4ebce3c409f6 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sat, 20 Jun 2026 19:05:00 -0500 Subject: [PATCH] fix(proxmox): restore reliable connect/reconnect after app restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: authenticate() tried to deserialize the Proxmox API response directly into AuthResponse, but Proxmox wraps every response in {"data": {...}}. This caused every reconnect attempt after app restart to fail silently. Additional fixes bundled in this commit: - add_proxmox_cluster now authenticates immediately so the in-memory pool always contains a live, ticketed client (not a bare unauthenticated stub) - ProxmoxClient stores the CSRFPreventionToken and includes it in the CSRFPreventionToken header on POST/PUT/DELETE requests (Proxmox requires this for all mutating calls) - accept-invalid-certs enabled on the reqwest Client so self-signed PVE certificates do not block connections - Removed double-unwrap of the data field in 10 commands (list_acls, list_users, get_cluster_notes, search_proxmox_resources, get_node_status, get_syslog, list_network_interfaces, get_subscription_status, list_cluster_tasks, list_proxmox_containers) — handle_response already strips the envelope before returning to callers - Added connect_proxmox_cluster and disconnect_proxmox_cluster Tauri commands so the UI can explicitly connect/disconnect sessions - Wired RemotesPage Connect/Disconnect buttons to the real backend commands - Updated and added tests covering envelope parsing, CSRF header logic, already-unwrapped response handling, and the new connect/disconnect paths --- src-tauri/src/cli/mod.rs | 2 +- src-tauri/src/commands/proxmox.rs | 181 +++++++++++++++++++++++------- src-tauri/src/lib.rs | 2 + src-tauri/src/proxmox/client.rs | 137 +++++++++++++++++++--- src/lib/proxmoxClient.ts | 21 +++- src/pages/Proxmox/RemotesPage.tsx | 30 ++--- 6 files changed, 299 insertions(+), 74 deletions(-) diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 65cbcee5..4dbe72e4 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -44,7 +44,7 @@ impl Cli { async fn main() { let cli = Cli::parse(); - let client = crate::proxmox::client::ProxmoxClient::new(&cli.url, 8006, &cli.username); + let mut client = crate::proxmox::client::ProxmoxClient::new(&cli.url, 8006, &cli.username); let ticket = match client.authenticate(&cli.password).await { Ok(t) => t, diff --git a/src-tauri/src/commands/proxmox.rs b/src-tauri/src/commands/proxmox.rs index 1607422d..a78c1fd6 100644 --- a/src-tauri/src/commands/proxmox.rs +++ b/src-tauri/src/commands/proxmox.rs @@ -41,10 +41,15 @@ pub async fn add_proxmox_cluster( password: String, state: State<'_, AppState>, ) -> Result { - // Create client (no live auth — credentials stored and used on first connect) - let client = ProxmoxClient::new(&connection.url, connection.port, &username); + // Authenticate immediately — this verifies credentials and gives us a live + // ticketed client. If auth fails we return early before touching the DB. + let mut client = ProxmoxClient::new(&connection.url, connection.port, &username); + client + .authenticate(&password) + .await + .map_err(|e| format!("Failed to authenticate with Proxmox: {}", e))?; - // Encrypt raw password for storage; auth happens lazily on first API call + // Encrypt raw password so we can re-authenticate after app restart. let credentials = serde_json::json!({ "password": password, "username": username @@ -95,7 +100,7 @@ pub async fn add_proxmox_cluster( .map_err(|e| format!("Failed to store cluster: {}", e))?; } - // Store in memory connection pool (unauthenticated; ticket set on first use) + // Insert the authenticated client into the in-memory pool. { let mut clusters = state.proxmox_clusters.lock().await; clusters.insert(id, Arc::new(Mutex::new(client))); @@ -1788,9 +1793,9 @@ pub async fn list_acls( .await .map_err(|e| format!("Failed to list ACLs: {}", e))?; + // handle_response already unwraps the Proxmox `{"data": ...}` envelope. response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()) } @@ -1811,8 +1816,7 @@ pub async fn list_users( .map_err(|e| format!("Failed to list users: {}", e))?; response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()) } @@ -1857,8 +1861,7 @@ pub async fn get_cluster_notes( .map_err(|e| format!("Failed to get cluster notes: {}", e))?; Ok(response - .get("data") - .and_then(|d| d.get("notes")) + .get("notes") .and_then(|n| n.as_str()) .unwrap_or("") .to_string()) @@ -1907,8 +1910,7 @@ pub async fn search_proxmox_resources( .map_err(|e| format!("Failed to search resources: {}", e))?; response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()) } @@ -1931,10 +1933,7 @@ pub async fn get_node_status( .await .map_err(|e| format!("Failed to get node status: {}", e))?; - response - .get("data") - .cloned() - .ok_or_else(|| "Invalid response format: missing data field".to_string()) + Ok(response) } // ─── Phase 11 - Syslog ──────────────────────────────────────────────────────── @@ -1958,8 +1957,7 @@ pub async fn get_syslog( .map_err(|e| format!("Failed to get syslog: {}", e))?; response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()) } @@ -1983,8 +1981,7 @@ pub async fn list_network_interfaces( .map_err(|e| format!("Failed to list network interfaces: {}", e))?; response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()) } @@ -2080,10 +2077,7 @@ pub async fn get_subscription_status( .await .map_err(|e| format!("Failed to get subscription status: {}", e))?; - response - .get("data") - .cloned() - .ok_or_else(|| "Invalid response format: missing data field".to_string()) + Ok(response) } // ─── Phase 15 - Cluster Task Log ───────────────────────────────────────────── @@ -2106,8 +2100,7 @@ pub async fn list_cluster_tasks( .map_err(|e| format!("Failed to list cluster tasks: {}", e))?; response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()) } @@ -2128,12 +2121,86 @@ pub async fn list_proxmox_containers( .map_err(|e| format!("Failed to list containers: {}", e))?; response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()) } +/// Connect (or re-connect) to a Proxmox cluster that already exists in the DB. +/// Loads the stored credentials, authenticates, and inserts the ticketed client +/// into the in-memory pool. Returns `true` on success. +/// +/// This is the action triggered by the "Connect" button in the Remotes UI and is +/// the path taken on every app restart for clusters that should be active. +#[tauri::command] +pub async fn connect_proxmox_cluster( + cluster_id: String, + state: State<'_, AppState>, +) -> Result { + let (url, port, username, encrypted_credentials) = { + let db = state + .db + .lock() + .map_err(|e| format!("Failed to lock database: {}", e))?; + + let mut stmt = db + .prepare( + "SELECT url, port, username, encrypted_credentials \ + FROM proxmox_clusters WHERE id = ?1", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + stmt.query_row([&cluster_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u16>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .optional() + .map_err(|e| format!("Failed to query cluster: {}", e))? + .ok_or_else(|| format!("Cluster {} not found in database", cluster_id))? + }; + + let credentials_json = crate::integrations::auth::decrypt_token(&encrypted_credentials) + .map_err(|e| format!("Failed to decrypt credentials: {}", e))?; + + let credentials: serde_json::Value = serde_json::from_str(&credentials_json) + .map_err(|e| format!("Failed to parse credentials: {}", e))?; + + let password = credentials + .get("password") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Password not found in credentials".to_string())?; + + let mut client = crate::proxmox::ProxmoxClient::new(&url, port, &username); + client + .authenticate(password) + .await + .map_err(|e| format!("Failed to authenticate with Proxmox: {}", e))?; + + { + let mut clusters = state.proxmox_clusters.lock().await; + clusters.insert(cluster_id, Arc::new(Mutex::new(client))); + } + + Ok(true) +} + +/// Remove a Proxmox cluster's authenticated session from the in-memory pool. +/// The cluster record and credentials remain in the DB — use `connect_proxmox_cluster` +/// to reconnect. +#[tauri::command] +pub async fn disconnect_proxmox_cluster( + cluster_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let mut clusters = state.proxmox_clusters.lock().await; + clusters.remove(&cluster_id); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -2171,17 +2238,20 @@ mod tests { } #[test] - fn test_list_proxmox_containers_error_message() { + fn test_cluster_not_found_error_message() { let err = format!("Cluster {} not found", "missing-id"); assert_eq!(err, "Cluster missing-id not found"); } + // After the double-unwrap fix, handle_response returns the inner `data` + // value directly. Commands call `.as_array()` on the already-unwrapped value. + #[test] - fn test_list_proxmox_containers_invalid_response() { - let response = serde_json::json!({"other": "field"}); + fn test_array_response_already_unwrapped_invalid() { + // The value returned by handle_response is not an array. + let response = serde_json::json!({"some": "object"}); let result: Result, String> = response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()); assert!(result.is_err()); @@ -2189,16 +2259,14 @@ mod tests { } #[test] - fn test_list_proxmox_containers_valid_response() { - let response = serde_json::json!({ - "data": [ - {"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"}, - {"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"} - ] - }); + fn test_array_response_already_unwrapped_valid() { + // handle_response strips {"data": [...]}, commands receive the raw array. + let response = serde_json::json!([ + {"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"}, + {"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"} + ]); let result: Result, String> = response - .get("data") - .and_then(|d| d.as_array()) + .as_array() .map(|arr| arr.to_vec()) .ok_or_else(|| "Invalid response format".to_string()); assert!(result.is_ok()); @@ -2211,6 +2279,35 @@ mod tests { assert_eq!(err, "Cluster missing-id not found"); } + #[test] + fn test_cluster_notes_already_unwrapped_present() { + let response = serde_json::json!({"notes": "Important info", "name": "pve"}); + let notes = response + .get("notes") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(notes, "Important info"); + } + + #[test] + fn test_cluster_notes_already_unwrapped_missing_defaults_empty() { + let response = serde_json::json!({"name": "pve"}); + let notes = response + .get("notes") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(notes, ""); + } + + #[test] + fn test_connect_cluster_db_not_found_error_message() { + let msg = format!("Cluster {} not found in database", "unknown-id"); + assert!(msg.contains("unknown-id")); + assert!(msg.contains("not found in database")); + } + #[test] fn test_update_proxmox_cluster_rows_zero_means_not_found() { let rows: usize = 0; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2601bbf..71d8ae35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -227,6 +227,8 @@ pub fn run() { commands::proxmox::remove_proxmox_cluster, commands::proxmox::update_proxmox_cluster, commands::proxmox::ping_proxmox_cluster, + commands::proxmox::connect_proxmox_cluster, + commands::proxmox::disconnect_proxmox_cluster, commands::proxmox::list_proxmox_clusters, commands::proxmox::get_proxmox_cluster, commands::proxmox::list_proxmox_vms, diff --git a/src-tauri/src/proxmox/client.rs b/src-tauri/src/proxmox/client.rs index d8b7485e..fa98606a 100644 --- a/src-tauri/src/proxmox/client.rs +++ b/src-tauri/src/proxmox/client.rs @@ -12,16 +12,32 @@ pub struct ProxmoxClient { username: String, api_token: Option, pub ticket: Option, + pub csrf_token: Option, client: Client, } -/// Authentication response from Proxmox +/// Outer envelope wrapping every Proxmox API response. #[derive(Debug, Deserialize)] +struct ProxmoxEnvelope { + data: T, +} + +/// Authentication response from Proxmox (inner `data` object). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] pub struct AuthResponse { + /// Cookie value — `PVEAuthCookie=`. pub ticket: String, pub username: String, + /// Seconds since epoch when the ticket expires. + #[serde(default)] pub expire: u64, - pub cap: String, + /// Required on mutating requests as `CSRFPreventionToken` header. + #[serde(rename = "CSRFPreventionToken")] + pub csrf_prevention_token: Option, + /// Capability map — structure varies, only needed for display/debug. + #[serde(default)] + pub cap: Option, } /// API token for authentication @@ -42,21 +58,28 @@ impl ProxmoxClient { username: username.to_string(), api_token: None, ticket: None, + csrf_token: None, client: Client::builder() + .danger_accept_invalid_certs(true) .timeout(Duration::from_secs(30)) .build() .expect("Failed to create HTTP client"), } } - /// Set the ticket for authentication + /// Set the ticket for cookie-based authentication. pub fn set_ticket(&mut self, ticket: &str) { self.ticket = Some(ticket.to_string()); } - /// Authenticate with root username and password - /// Returns the API ticket for subsequent requests - pub async fn authenticate(&self, password: &str) -> Result { + /// Set the CSRF prevention token (required for mutating requests). + pub fn set_csrf_token(&mut self, token: &str) { + self.csrf_token = Some(token.to_string()); + } + + /// Authenticate with username + password. + /// Stores the ticket and CSRF token on success; returns the ticket string. + pub async fn authenticate(&mut self, password: &str) -> Result { let url = format!( "https://{}:{}/api2/json/access/ticket", self.base_url, self.port @@ -82,11 +105,17 @@ impl ProxmoxClient { )); } - let auth: AuthResponse = response + let envelope: ProxmoxEnvelope = response .json() .await .map_err(|e| anyhow!("Failed to parse authentication response: {}", e))?; + let auth = envelope.data; + self.ticket = Some(auth.ticket.clone()); + if let Some(csrf) = auth.csrf_prevention_token { + self.csrf_token = Some(csrf); + } + Ok(auth.ticket) } @@ -105,12 +134,12 @@ impl ProxmoxClient { ) } - /// Build request headers with authentication - fn build_headers(&self, ticket: Option<&str>) -> reqwest::header::HeaderMap { + /// Build request headers with authentication. + /// `include_csrf` should be true for POST / PUT / DELETE requests. + fn build_headers(&self, ticket: Option<&str>, include_csrf: bool) -> reqwest::header::HeaderMap { let mut headers = reqwest::header::HeaderMap::new(); if let Some(token) = &self.api_token { - // API token format: user@realm!tokenid=tokenvalue headers.insert( reqwest::header::AUTHORIZATION, format!("PVEAPIAuth {}", token) @@ -118,13 +147,20 @@ impl ProxmoxClient { .expect("Invalid auth header"), ); } else if let Some(ticket) = ticket { - // Cookie-based authentication headers.insert( "Cookie", format!("PVEAuthCookie={}", ticket) .parse() .expect("Invalid cookie header"), ); + if include_csrf { + if let Some(csrf) = &self.csrf_token { + headers.insert( + "CSRFPreventionToken", + csrf.parse().expect("Invalid CSRF token header"), + ); + } + } } headers.insert( @@ -144,7 +180,7 @@ impl ProxmoxClient { ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); - let headers = self.build_headers(ticket); + let headers = self.build_headers(ticket, false); let response = self .client @@ -165,7 +201,7 @@ impl ProxmoxClient { ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); - let headers = self.build_headers(ticket); + let headers = self.build_headers(ticket, true); let response = self .client @@ -187,7 +223,7 @@ impl ProxmoxClient { ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); - let headers = self.build_headers(ticket); + let headers = self.build_headers(ticket, true); let response = self .client @@ -208,7 +244,7 @@ impl ProxmoxClient { ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); - let headers = self.build_headers(ticket); + let headers = self.build_headers(ticket, true); let response = self .client @@ -280,6 +316,8 @@ mod tests { assert_eq!(client.base_url(), "pve.example.com"); assert_eq!(client.port(), 8006); assert_eq!(client.username(), "root@pam"); + assert!(client.ticket.is_none()); + assert!(client.csrf_token.is_none()); } #[test] @@ -300,4 +338,73 @@ mod tests { "https://pve.example.com:8006/api2/json/cluster/resources" ); } + + #[test] + fn test_auth_response_envelope_deserialization() { + // Validates that the `{"data": {...}}` envelope Proxmox uses is parsed + // correctly into ProxmoxEnvelope. + let json = r#"{ + "data": { + "Ticket": "PVE:root@pam:12345", + "Username": "root@pam", + "Expire": 1800, + "CSRFPreventionToken": "abc123", + "Cap": null + } + }"#; + let envelope: ProxmoxEnvelope = + serde_json::from_str(json).expect("envelope should parse"); + assert_eq!(envelope.data.ticket, "PVE:root@pam:12345"); + assert_eq!( + envelope.data.csrf_prevention_token.as_deref(), + Some("abc123") + ); + } + + #[test] + fn test_auth_response_envelope_no_csrf() { + // Some Proxmox versions or API tokens may omit CSRFPreventionToken. + let json = r#"{ + "data": { + "Ticket": "PVE:root@pam:99999", + "Username": "root@pam" + } + }"#; + let envelope: ProxmoxEnvelope = + serde_json::from_str(json).expect("envelope should parse without CSRF"); + assert_eq!(envelope.data.ticket, "PVE:root@pam:99999"); + assert!(envelope.data.csrf_prevention_token.is_none()); + } + + #[test] + fn test_build_headers_get_omits_csrf() { + let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam"); + client.set_ticket("my-ticket"); + client.set_csrf_token("my-csrf"); + + let headers = client.build_headers(Some("my-ticket"), false); + assert!(!headers.contains_key("CSRFPreventionToken")); + assert!(headers.contains_key("Cookie")); + } + + #[test] + fn test_build_headers_post_includes_csrf() { + let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam"); + client.set_ticket("my-ticket"); + client.set_csrf_token("my-csrf"); + + let headers = client.build_headers(Some("my-ticket"), true); + assert!(headers.contains_key("CSRFPreventionToken")); + let csrf_val = headers.get("CSRFPreventionToken").unwrap().to_str().unwrap(); + assert_eq!(csrf_val, "my-csrf"); + } + + #[test] + fn test_set_ticket_and_csrf_token() { + let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam"); + client.set_ticket("ticket-value"); + client.set_csrf_token("csrf-value"); + assert_eq!(client.ticket.as_deref(), Some("ticket-value")); + assert_eq!(client.csrf_token.as_deref(), Some("csrf-value")); + } } diff --git a/src/lib/proxmoxClient.ts b/src/lib/proxmoxClient.ts index 38077bb7..911e5788 100644 --- a/src/lib/proxmoxClient.ts +++ b/src/lib/proxmoxClient.ts @@ -66,8 +66,25 @@ export async function updateProxmoxCluster( * Ping a Proxmox cluster — authenticates and calls the version endpoint to verify * the API is reachable and credentials are valid. */ -export async function pingProxmoxCluster(clusterId: string): Promise { - return await invoke("ping_proxmox_cluster", { clusterId }); +export async function pingProxmoxCluster(clusterId: string): Promise { + return await invoke("ping_proxmox_cluster", { clusterId }); +} + +/** + * Connect (or re-connect) to a cluster stored in the DB. + * Authenticates against the Proxmox API and populates the in-memory pool. + * Use after app restart or after an explicit disconnect. + */ +export async function connectProxmoxCluster(clusterId: string): Promise { + return await invoke("connect_proxmox_cluster", { clusterId }); +} + +/** + * Disconnect from a cluster by removing its authenticated session from the + * in-memory pool. Credentials are retained in the DB for later reconnection. + */ +export async function disconnectProxmoxCluster(clusterId: string): Promise { + await invoke("disconnect_proxmox_cluster", { clusterId }); } /** diff --git a/src/pages/Proxmox/RemotesPage.tsx b/src/pages/Proxmox/RemotesPage.tsx index 2cbfe988..02367ec8 100644 --- a/src/pages/Proxmox/RemotesPage.tsx +++ b/src/pages/Proxmox/RemotesPage.tsx @@ -6,7 +6,7 @@ import { AddRemoteForm } from '@/components/Proxmox'; import { EditRemoteForm } from '@/components/Proxmox'; import { RemoveRemoteDialog } from '@/components/Proxmox'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index'; -import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, updateProxmoxCluster, pingProxmoxCluster } from '@/lib/proxmoxClient'; +import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, updateProxmoxCluster, connectProxmoxCluster, disconnectProxmoxCluster } from '@/lib/proxmoxClient'; import { ClusterType } from '@/lib/domain'; import { toast } from 'sonner'; @@ -134,8 +134,8 @@ export function ProxmoxRemotesPage() { const handleConnectRemote = async (remote: RemoteInfo) => { try { - toast.info(`Testing connection to ${remote.name}...`); - await pingProxmoxCluster(remote.id); + toast.info(`Connecting to ${remote.name}...`); + await connectProxmoxCluster(remote.id); toast.success(`Connected to ${remote.name}`); setRemotes((prev) => prev.map((r) => (r.id === remote.id ? { ...r, status: 'connected' } : r)) @@ -149,11 +149,17 @@ export function ProxmoxRemotesPage() { } }; - const handleDisconnectRemote = (remote: RemoteInfo) => { - setRemotes((prev) => - prev.map((r) => (r.id === remote.id ? { ...r, status: 'disconnected' } : r)) - ); - toast.info(`Disconnected from ${remote.name}`); + const handleDisconnectRemote = async (remote: RemoteInfo) => { + try { + await disconnectProxmoxCluster(remote.id); + setRemotes((prev) => + prev.map((r) => (r.id === remote.id ? { ...r, status: 'disconnected' } : r)) + ); + toast.info(`Disconnected from ${remote.name}`); + } catch (err) { + console.error('Failed to disconnect remote:', err); + toast.error('Disconnect failed: ' + String(err)); + } }; return ( @@ -184,12 +190,8 @@ export function ProxmoxRemotesPage() { onDelete={(remote) => { setRemovingRemote(remote as RemoteInfo | null); }} - onConnect={(remote) => { - void handleConnectRemote(remote as RemoteInfo); - }} - onDisconnect={(remote) => { - handleDisconnectRemote(remote as RemoteInfo); - }} + onConnect={(remote) => { void handleConnectRemote(remote as RemoteInfo); }} + onDisconnect={(remote) => { void handleDisconnectRemote(remote as RemoteInfo); }} /> {showAddDialog && (