fix: persist integration settings and implement persistent browser windows
Some checks failed
Release / build-macos-arm64 (push) Successful in 4m52s
Release / build-linux-amd64 (push) Has been cancelled
Release / build-linux-arm64 (push) Has been cancelled
Release / build-windows-amd64 (push) Has been cancelled

## Integration Settings Persistence
- Add database commands to save/load integration configs (base_url, username, project_name, space_key)
- Frontend now loads configs from DB on mount and saves changes automatically
- Fixes issue where settings were lost on app restart

## Persistent Browser Window Architecture
- Integration browser windows now stay open for user browsing and authentication
- Extract fresh cookies before each API call to handle token rotation
- Track open windows in app state (integration_webviews HashMap)
- Windows titled as "{Service} Browser (TFTSR)" for clarity
- Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab)
- Gracefully handle closed windows with automatic cleanup

## Bug Fixes
- Fix Rust formatting issues across 8 files
- Fix clippy warnings:
  - Use is_some_and() instead of map_or() in openai.rs
  - Use .to_string() instead of format!() in integrations.rs
- Add missing OptionalExtension import for .optional() method

## Tests
- Add test_integration_config_serialization
- Add test_webview_tracking
- Add test_token_auth_request_serialization
- All 6 integration tests passing

## Files Modified
- src-tauri/src/state.rs: Add integration_webviews tracking
- src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap
- src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines)
- src-tauri/src/integrations/webview_auth.rs: Persistent window behavior
- src/lib/tauriCommands.ts: TypeScript wrappers for new commands
- src/pages/Settings/Integrations.tsx: Load/save configs from DB

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Shaun Arman 2026-04-04 09:57:22 -05:00
parent fbce897608
commit a7903db904
11 changed files with 446 additions and 67 deletions

View File

@ -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;
}

View File

@ -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<Mutex<HashMap<String, String>>> =
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<WebviewAuthResponse, String> {
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<Option<Vec<crate::integrations::webview_auth::Cookie>>, 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<String>,
pub project_name: Option<String>,
pub space_key: Option<String>,
}
/// 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<Option<IntegrationConfig>, 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<Vec<IntegrationConfig>, 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::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to collect integration configs: {}", e))?;
Ok(configs)
}

View File

@ -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()

View File

@ -25,7 +25,10 @@ pub struct Page {
/// Test connection to Confluence by fetching current user info
pub async fn test_connection(config: &ConfluenceConfig) -> Result<ConnectionResult, String> {
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#"{

View File

@ -36,9 +36,7 @@ pub enum AuthMethod {
expires_at: Option<i64>,
},
#[serde(rename = "cookies")]
Cookies {
cookies: Vec<webview_auth::Cookie>,
},
Cookies { cookies: Vec<webview_auth::Cookie> },
#[serde(rename = "token")]
Token {
token: String,

View File

@ -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#"{

View File

@ -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

View File

@ -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,

View File

@ -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<Mutex<rusqlite::Connection>>,
pub settings: Arc<Mutex<AppSettings>>,
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<Mutex<HashMap<String, String>>>,
}

View File

@ -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<WebviewAuthResponse>("authenticate_with_webview", { service, baseUrl });
@ -417,3 +425,14 @@ export const extractCookiesFromWebviewCmd = (service: string, webviewId: string)
export const saveManualTokenCmd = (request: TokenAuthRequest) =>
invoke<ConnectionResult>("save_manual_token", { request });
// ─── Integration Configuration Persistence ────────────────────────────────────
export const saveIntegrationConfigCmd = (config: IntegrationConfig) =>
invoke<void>("save_integration_config", { config });
export const getIntegrationConfigCmd = (service: string) =>
invoke<IntegrationConfig | null>("get_integration_config", { service });
export const getAllIntegrationConfigsCmd = () =>
invoke<IntegrationConfig[]>("get_all_integration_configs");

View File

@ -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<Record<string, boolean>>({});
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string } | null>>({});
// Load configs from database on mount
useEffect(() => {
const loadConfigs = async () => {
try {
const savedConfigs = await getAllIntegrationConfigsCmd();
const configMap: Record<string, Partial<IntegrationConfig>> = {};
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) => {