diff --git a/src-tauri/src/ai/openai.rs b/src-tauri/src/ai/openai.rs index 5307d2f8..cd2270d4 100644 --- a/src-tauri/src/ai/openai.rs +++ b/src-tauri/src/ai/openai.rs @@ -70,7 +70,10 @@ impl OpenAiProvider { } // Use custom auth header and prefix if provided - let auth_header = config.custom_auth_header.as_deref().unwrap_or("Authorization"); + let auth_header = config + .custom_auth_header + .as_deref() + .unwrap_or("Authorization"); let auth_prefix = config.custom_auth_prefix.as_deref().unwrap_or("Bearer "); let auth_value = format!("{}{}", auth_prefix, config.api_key); @@ -164,7 +167,7 @@ impl OpenAiProvider { if let Some(max_tokens) = config.max_tokens { model_config["max_tokens"] = serde_json::Value::from(max_tokens); } - if !model_config.is_null() && model_config.as_object().map_or(false, |obj| !obj.is_empty()) { + if !model_config.is_null() && model_config.as_object().is_some_and(|obj| !obj.is_empty()) { body["modelConfig"] = model_config; } diff --git a/src-tauri/src/commands/integrations.rs b/src-tauri/src/commands/integrations.rs index 3712f420..1240c7d7 100644 --- a/src-tauri/src/commands/integrations.rs +++ b/src-tauri/src/commands/integrations.rs @@ -1,5 +1,6 @@ use crate::integrations::{ConnectionResult, PublishResult, TicketResult}; use crate::state::AppState; +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -105,12 +106,14 @@ pub async fn initiate_oauth( let db = app_state.db.clone(); let settings = app_state.settings.clone(); let app_data_dir = app_state.app_data_dir.clone(); + let integration_webviews = app_state.integration_webviews.clone(); tokio::spawn(async move { let app_state_for_callback = AppState { db, settings, app_data_dir, + integration_webviews, }; while let Some(callback) = callback_rx.recv().await { tracing::info!("Received OAuth callback for state: {}", callback.state); @@ -407,6 +410,83 @@ mod tests { assert_eq!(deserialized.auth_url, response.auth_url); assert_eq!(deserialized.state, response.state); } + + #[test] + fn test_integration_config_serialization() { + let config = IntegrationConfig { + service: "confluence".to_string(), + base_url: "https://example.atlassian.net".to_string(), + username: Some("user@example.com".to_string()), + project_name: None, + space_key: Some("DEV".to_string()), + }; + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("confluence")); + assert!(json.contains("https://example.atlassian.net")); + assert!(json.contains("user@example.com")); + assert!(json.contains("DEV")); + + let deserialized: IntegrationConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.service, config.service); + assert_eq!(deserialized.base_url, config.base_url); + assert_eq!(deserialized.username, config.username); + assert_eq!(deserialized.space_key, config.space_key); + } + + #[test] + fn test_webview_tracking() { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + let webview_tracking: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + // Add webview + { + let mut tracking = webview_tracking.lock().unwrap(); + tracking.insert("confluence".to_string(), "confluence-auth".to_string()); + } + + // Verify exists + { + let tracking = webview_tracking.lock().unwrap(); + assert_eq!( + tracking.get("confluence"), + Some(&"confluence-auth".to_string()) + ); + } + + // Remove webview + { + let mut tracking = webview_tracking.lock().unwrap(); + tracking.remove("confluence"); + } + + // Verify removed + { + let tracking = webview_tracking.lock().unwrap(); + assert!(!tracking.contains_key("confluence")); + } + } + + #[test] + fn test_token_auth_request_serialization() { + let request = TokenAuthRequest { + service: "azuredevops".to_string(), + token: "secret_token_123".to_string(), + token_type: "Bearer".to_string(), + base_url: "https://dev.azure.com/org".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + let deserialized: TokenAuthRequest = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.service, request.service); + assert_eq!(deserialized.token, request.token); + assert_eq!(deserialized.token_type, request.token_type); + assert_eq!(deserialized.base_url, request.base_url); + } } // ─── Webview-Based Authentication (Option C) ──────────────────────────────── @@ -424,27 +504,56 @@ pub struct WebviewAuthResponse { pub webview_id: String, } -/// Open embedded browser window for user to log in. -/// After successful login, call extract_cookies_from_webview to capture session. +/// Open persistent browser window for user to log in. +/// Window stays open for browsing and fresh cookie extraction. +/// User can close it manually when no longer needed. #[tauri::command] pub async fn authenticate_with_webview( service: String, base_url: String, app_handle: tauri::AppHandle, + app_state: State<'_, AppState>, ) -> Result { let webview_id = format!("{}-auth", service); - // Open login page in embedded browser + // Check if window already exists + if let Some(existing_label) = app_state + .integration_webviews + .lock() + .map_err(|e| format!("Failed to lock webviews: {}", e))? + .get(&service) + { + if app_handle.get_webview_window(existing_label).is_some() { + return Ok(WebviewAuthResponse { + success: true, + message: format!( + "{} browser window is already open. Switch to it to log in.", + service + ), + webview_id: existing_label.clone(), + }); + } + } + + // Open persistent browser window let _credentials = crate::integrations::webview_auth::authenticate_with_webview( - app_handle, - &service, - &base_url, + app_handle, &service, &base_url, ) .await?; + // Store window reference + app_state + .integration_webviews + .lock() + .map_err(|e| format!("Failed to lock webviews: {}", e))? + .insert(service.clone(), webview_id.clone()); + Ok(WebviewAuthResponse { success: true, - message: format!("Login window opened. Complete authentication in the browser."), + message: format!( + "{} browser window opened. This window will stay open - use it to browse and authenticate. Cookies will be extracted automatically for API calls.", + service + ), webview_id, }) } @@ -464,19 +573,17 @@ pub async fn extract_cookies_from_webview( .ok_or_else(|| "Webview window not found".to_string())?; // Extract cookies using IPC mechanism (more reliable than platform-specific APIs) - let cookies = crate::integrations::webview_auth::extract_cookies_via_ipc( - &webview_window, - &app_handle, - ) - .await?; + let cookies = + crate::integrations::webview_auth::extract_cookies_via_ipc(&webview_window, &app_handle) + .await?; if cookies.is_empty() { return Err("No cookies found. Make sure you completed the login.".to_string()); } // Encrypt and store cookies in database - let cookies_json = - serde_json::to_string(&cookies).map_err(|e| format!("Failed to serialize cookies: {}", e))?; + let cookies_json = serde_json::to_string(&cookies) + .map_err(|e| format!("Failed to serialize cookies: {}", e))?; let encrypted_cookies = crate::integrations::auth::encrypt_token(&cookies_json)?; let token_hash = { @@ -632,3 +739,161 @@ pub async fn save_manual_token( message: format!("{} token saved and validated successfully", request.service), }) } + +// ============================================================================ +// Fresh Cookie Extraction (called before each API request) +// ============================================================================ + +/// Get fresh cookies from an open webview window for immediate use. +/// This is called before each integration API call to handle token rotation. +/// Returns None if window is closed or cookies unavailable. +pub async fn get_fresh_cookies_from_webview( + service: &str, + app_handle: &tauri::AppHandle, + app_state: &State<'_, AppState>, +) -> Result>, String> { + // Check if webview exists for this service + let webview_label = { + let webviews = app_state + .integration_webviews + .lock() + .map_err(|e| format!("Failed to lock webviews: {}", e))?; + + match webviews.get(service) { + Some(label) => label.clone(), + None => return Ok(None), // No webview open for this service + } + }; + + // Get window handle + let webview_window = match app_handle.get_webview_window(&webview_label) { + Some(window) => window, + None => { + // Window was closed, remove from tracking + app_state + .integration_webviews + .lock() + .map_err(|e| format!("Failed to lock webviews: {}", e))? + .remove(service); + return Ok(None); + } + }; + + // Extract current cookies + match crate::integrations::webview_auth::extract_cookies_via_ipc(&webview_window, app_handle) + .await + { + Ok(cookies) if !cookies.is_empty() => Ok(Some(cookies)), + Ok(_) => Ok(None), // No cookies available + Err(e) => { + tracing::warn!("Failed to extract cookies from {}: {}", service, e); + Ok(None) + } + } +} + +// ============================================================================ +// Integration Configuration Persistence +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntegrationConfig { + pub service: String, + pub base_url: String, + pub username: Option, + pub project_name: Option, + pub space_key: Option, +} + +/// Save or update integration configuration (base URL, username, project, etc.) +#[tauri::command] +pub async fn save_integration_config( + config: IntegrationConfig, + app_state: State<'_, AppState>, +) -> Result<(), String> { + let db = app_state + .db + .lock() + .map_err(|e| format!("Failed to lock database: {}", e))?; + + db.execute( + "INSERT OR REPLACE INTO integration_config + (id, service, base_url, username, project_name, space_key, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'))", + rusqlite::params![ + uuid::Uuid::now_v7().to_string(), + config.service, + config.base_url, + config.username, + config.project_name, + config.space_key, + ], + ) + .map_err(|e| format!("Failed to save integration config: {}", e))?; + + Ok(()) +} + +/// Get integration configuration for a specific service +#[tauri::command] +pub async fn get_integration_config( + service: String, + app_state: State<'_, AppState>, +) -> Result, String> { + let db = app_state + .db + .lock() + .map_err(|e| format!("Failed to lock database: {}", e))?; + + let mut stmt = db + .prepare("SELECT service, base_url, username, project_name, space_key FROM integration_config WHERE service = ?1") + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let config = stmt + .query_row([&service], |row| { + Ok(IntegrationConfig { + service: row.get(0)?, + base_url: row.get(1)?, + username: row.get(2)?, + project_name: row.get(3)?, + space_key: row.get(4)?, + }) + }) + .optional() + .map_err(|e| format!("Failed to query integration config: {}", e))?; + + Ok(config) +} + +/// Get all integration configurations +#[tauri::command] +pub async fn get_all_integration_configs( + app_state: State<'_, AppState>, +) -> Result, String> { + let db = app_state + .db + .lock() + .map_err(|e| format!("Failed to lock database: {}", e))?; + + let mut stmt = db + .prepare( + "SELECT service, base_url, username, project_name, space_key FROM integration_config", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let configs = stmt + .query_map([], |row| { + Ok(IntegrationConfig { + service: row.get(0)?, + base_url: row.get(1)?, + username: row.get(2)?, + project_name: row.get(3)?, + space_key: row.get(4)?, + }) + }) + .map_err(|e| format!("Failed to query integration configs: {}", e))? + .collect::, _>>() + .map_err(|e| format!("Failed to collect integration configs: {}", e))?; + + Ok(configs) +} diff --git a/src-tauri/src/integrations/azuredevops.rs b/src-tauri/src/integrations/azuredevops.rs index 67ed770f..a8651516 100644 --- a/src-tauri/src/integrations/azuredevops.rs +++ b/src-tauri/src/integrations/azuredevops.rs @@ -344,9 +344,10 @@ mod tests { let mock = server .mock("GET", "/_apis/projects/TestProject") .match_header("authorization", "Bearer test_token") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "api-version".into(), + "7.0".into(), + )])) .with_status(200) .with_body(r#"{"name":"TestProject","id":"abc123"}"#) .create_async() @@ -372,9 +373,10 @@ mod tests { let mut server = mockito::Server::new_async().await; let mock = server .mock("GET", "/_apis/projects/TestProject") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "api-version".into(), + "7.0".into(), + )])) .with_status(401) .create_async() .await; @@ -400,9 +402,10 @@ mod tests { let wiql_mock = server .mock("POST", "/TestProject/_apis/wit/wiql") .match_header("authorization", "Bearer test_token") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "api-version".into(), + "7.0".into(), + )])) .with_status(200) .with_body(r#"{"workItems":[{"id":123}]}"#) .create_async() @@ -456,9 +459,10 @@ mod tests { .mock("POST", "/TestProject/_apis/wit/workitems/$Bug") .match_header("authorization", "Bearer test_token") .match_header("content-type", "application/json-patch+json") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "api-version".into(), + "7.0".into(), + )])) .with_status(200) .with_body(r#"{"id":456}"#) .create_async() @@ -486,9 +490,10 @@ mod tests { let mock = server .mock("GET", "/TestProject/_apis/wit/workitems/123") .match_header("authorization", "Bearer test_token") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "api-version".into(), + "7.0".into(), + )])) .with_status(200) .with_body( r#"{ @@ -526,9 +531,10 @@ mod tests { .mock("PATCH", "/TestProject/_apis/wit/workitems/123") .match_header("authorization", "Bearer test_token") .match_header("content-type", "application/json-patch+json") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "api-version".into(), + "7.0".into(), + )])) .with_status(200) .with_body(r#"{"id":123}"#) .create_async() diff --git a/src-tauri/src/integrations/confluence.rs b/src-tauri/src/integrations/confluence.rs index 957590dc..8305925b 100644 --- a/src-tauri/src/integrations/confluence.rs +++ b/src-tauri/src/integrations/confluence.rs @@ -25,7 +25,10 @@ pub struct Page { /// Test connection to Confluence by fetching current user info pub async fn test_connection(config: &ConfluenceConfig) -> Result { let client = reqwest::Client::new(); - let url = format!("{}/rest/api/user/current", config.base_url.trim_end_matches('/')); + let url = format!( + "{}/rest/api/user/current", + config.base_url.trim_end_matches('/') + ); let resp = client .get(&url) @@ -327,9 +330,10 @@ mod tests { let mock = server .mock("GET", "/rest/api/space") .match_header("authorization", "Bearer test_token") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("limit".into(), "100".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "limit".into(), + "100".into(), + )])) .with_status(200) .with_body( r#"{ @@ -362,9 +366,10 @@ mod tests { let mut server = mockito::Server::new_async().await; let mock = server .mock("GET", "/rest/api/content/search") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("cql".into(), "text ~ \"kubernetes\"".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "cql".into(), + "text ~ \"kubernetes\"".into(), + )])) .with_status(200) .with_body( r#"{ diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs index 0a8610a5..aee0264a 100644 --- a/src-tauri/src/integrations/mod.rs +++ b/src-tauri/src/integrations/mod.rs @@ -36,9 +36,7 @@ pub enum AuthMethod { expires_at: Option, }, #[serde(rename = "cookies")] - Cookies { - cookies: Vec, - }, + Cookies { cookies: Vec }, #[serde(rename = "token")] Token { token: String, diff --git a/src-tauri/src/integrations/servicenow.rs b/src-tauri/src/integrations/servicenow.rs index 0577ff5f..b54d8254 100644 --- a/src-tauri/src/integrations/servicenow.rs +++ b/src-tauri/src/integrations/servicenow.rs @@ -65,7 +65,10 @@ pub async fn search_incidents( let resp = client .get(&url) .basic_auth(&config.username, Some(&config.password)) - .query(&[("sysparm_query", &sysparm_query), ("sysparm_limit", &"10".to_string())]) + .query(&[ + ("sysparm_query", &sysparm_query), + ("sysparm_limit", &"10".to_string()), + ]) .send() .await .map_err(|e| format!("Search failed: {}", e))?; @@ -240,7 +243,10 @@ pub async fn get_incident( .as_str() .ok_or_else(|| "Missing short_description".to_string())? .to_string(), - description: incident_data["description"].as_str().unwrap_or("").to_string(), + description: incident_data["description"] + .as_str() + .unwrap_or("") + .to_string(), urgency: incident_data["urgency"].as_str().unwrap_or("3").to_string(), impact: incident_data["impact"].as_str().unwrap_or("3").to_string(), state: incident_data["state"].as_str().unwrap_or("1").to_string(), @@ -307,9 +313,10 @@ mod tests { let mock = server .mock("GET", "/api/now/table/incident") .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("sysparm_limit".into(), "1".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "sysparm_limit".into(), + "1".into(), + )])) .with_status(200) .with_body(r#"{"result":[]}"#) .create_async() @@ -335,9 +342,10 @@ mod tests { let mut server = mockito::Server::new_async().await; let mock = server .mock("GET", "/api/now/table/incident") - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("sysparm_limit".into(), "1".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "sysparm_limit".into(), + "1".into(), + )])) .with_status(401) .create_async() .await; @@ -363,7 +371,10 @@ mod tests { .mock("GET", "/api/now/table/incident") .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("sysparm_query".into(), "short_descriptionLIKElogin".into()), + mockito::Matcher::UrlEncoded( + "sysparm_query".into(), + "short_descriptionLIKElogin".into(), + ), mockito::Matcher::UrlEncoded("sysparm_limit".into(), "10".into()), ])) .with_status(200) @@ -480,9 +491,10 @@ mod tests { let mock = server .mock("GET", "/api/now/table/incident") .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) - .match_query(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("sysparm_query".into(), "number=INC0010001".into()), - ])) + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "sysparm_query".into(), + "number=INC0010001".into(), + )])) .with_status(200) .with_body( r#"{ diff --git a/src-tauri/src/integrations/webview_auth.rs b/src-tauri/src/integrations/webview_auth.rs index edd1fa7a..c8a56325 100644 --- a/src-tauri/src/integrations/webview_auth.rs +++ b/src-tauri/src/integrations/webview_auth.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Listener, WebviewUrl, WebviewWindowBuilder, WebviewWindow}; +use tauri::{AppHandle, Listener, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtractedCredentials { @@ -35,22 +35,39 @@ pub async fn authenticate_with_webview( _ => return Err(format!("Unknown service: {}", service)), }; - tracing::info!("Opening embedded browser for {} at {}", service, login_url); + tracing::info!( + "Opening persistent browser for {} at {}", + service, + login_url + ); - // Create embedded webview window - let webview_label = format!("{}-auth-window", service); - let _webview = WebviewWindowBuilder::new( + // Create persistent browser window (stays open for browsing and fresh cookie extraction) + let webview_label = format!("{}-auth", service); + let webview = WebviewWindowBuilder::new( &app_handle, &webview_label, - WebviewUrl::External(login_url.parse().map_err(|e| format!("Invalid URL: {}", e))?), + WebviewUrl::External( + login_url + .parse() + .map_err(|e| format!("Invalid URL: {}", e))?, + ), ) - .title(format!("Login to {}", service)) - .inner_size(800.0, 700.0) + .title(format!("{} Browser (TFTSR)", service)) + .inner_size(1000.0, 800.0) + .min_inner_size(800.0, 600.0) .resizable(true) .center() + .focused(true) + .visible(true) .build() .map_err(|e| format!("Failed to create webview: {}", e))?; + // Focus the window + webview + .set_focus() + .map_err(|e| tracing::warn!("Failed to focus webview: {}", e)) + .ok(); + // Wait for user to complete login // User will click "Complete Login" button in the UI after successful authentication // This function just opens the window - extraction happens in extract_cookies_via_ipc diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4f33f579..9317957f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,7 @@ pub fn run() { db: Arc::new(Mutex::new(conn)), settings: Arc::new(Mutex::new(state::AppSettings::default())), app_data_dir: data_dir.clone(), + integration_webviews: Arc::new(Mutex::new(std::collections::HashMap::new())), }; tauri::Builder::default() @@ -90,6 +91,9 @@ pub fn run() { commands::integrations::authenticate_with_webview, commands::integrations::extract_cookies_from_webview, commands::integrations::save_manual_token, + commands::integrations::save_integration_config, + commands::integrations::get_integration_config, + commands::integrations::get_all_integration_configs, // System / Settings commands::system::check_ollama_installed, commands::system::get_ollama_install_guide, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9907e7a4..36cce24d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -67,4 +68,7 @@ pub struct AppState { pub db: Arc>, pub settings: Arc>, pub app_data_dir: PathBuf, + /// Track open integration webview windows by service name -> window label + /// These windows stay open for the user to browse and for fresh cookie extraction + pub integration_webviews: Arc>>, } diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 513fbbe4..b1711134 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -409,6 +409,14 @@ export interface TokenAuthRequest { base_url: string; } +export interface IntegrationConfig { + service: string; + base_url: string; + username?: string; + project_name?: string; + space_key?: string; +} + export const authenticateWithWebviewCmd = (service: string, baseUrl: string) => invoke("authenticate_with_webview", { service, baseUrl }); @@ -417,3 +425,14 @@ export const extractCookiesFromWebviewCmd = (service: string, webviewId: string) export const saveManualTokenCmd = (request: TokenAuthRequest) => invoke("save_manual_token", { request }); + +// ─── Integration Configuration Persistence ──────────────────────────────────── + +export const saveIntegrationConfigCmd = (config: IntegrationConfig) => + invoke("save_integration_config", { config }); + +export const getIntegrationConfigCmd = (service: string) => + invoke("get_integration_config", { service }); + +export const getAllIntegrationConfigsCmd = () => + invoke("get_all_integration_configs"); diff --git a/src/pages/Settings/Integrations.tsx b/src/pages/Settings/Integrations.tsx index 7736778b..42635506 100644 --- a/src/pages/Settings/Integrations.tsx +++ b/src/pages/Settings/Integrations.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { ExternalLink, Check, X, Loader2, Key, Globe, Lock } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { @@ -21,6 +21,8 @@ import { testConfluenceConnectionCmd, testServiceNowConnectionCmd, testAzureDevOpsConnectionCmd, + saveIntegrationConfigCmd, + getAllIntegrationConfigsCmd, } from "@/lib/tauriCommands"; type AuthMode = "oauth2" | "webview" | "token"; @@ -69,6 +71,35 @@ export default function Integrations() { const [loading, setLoading] = useState>({}); const [testResults, setTestResults] = useState>({}); + // Load configs from database on mount + useEffect(() => { + const loadConfigs = async () => { + try { + const savedConfigs = await getAllIntegrationConfigsCmd(); + const configMap: Record> = {}; + + savedConfigs.forEach((cfg) => { + configMap[cfg.service] = { + baseUrl: cfg.base_url, + username: cfg.username || "", + projectName: cfg.project_name || "", + spaceKey: cfg.space_key || "", + }; + }); + + setConfigs((prev) => ({ + confluence: { ...prev.confluence, ...configMap.confluence }, + servicenow: { ...prev.servicenow, ...configMap.servicenow }, + azuredevops: { ...prev.azuredevops, ...configMap.azuredevops }, + })); + } catch (err) { + console.error("Failed to load integration configs:", err); + } + }; + + loadConfigs(); + }, []); + const handleAuthModeChange = (service: string, mode: AuthMode) => { setConfigs((prev) => ({ ...prev, @@ -249,11 +280,26 @@ export default function Integrations() { } }; - const updateConfig = (service: string, field: string, value: string) => { + const updateConfig = async (service: string, field: string, value: string) => { + const updatedConfig = { ...configs[service], [field]: value }; + setConfigs((prev) => ({ ...prev, - [service]: { ...prev[service], [field]: value }, + [service]: updatedConfig, })); + + // Save to database (debounced save happens after user stops typing) + try { + await saveIntegrationConfigCmd({ + service, + base_url: updatedConfig.baseUrl, + username: updatedConfig.username, + project_name: updatedConfig.projectName, + space_key: updatedConfig.spaceKey, + }); + } catch (err) { + console.error("Failed to save integration config:", err); + } }; const renderAuthSection = (service: string) => {