diff --git a/src-tauri/src/commands/integrations.rs b/src-tauri/src/commands/integrations.rs index d105928b..ba3190a0 100644 --- a/src-tauri/src/commands/integrations.rs +++ b/src-tauri/src/commands/integrations.rs @@ -1,8 +1,9 @@ use crate::integrations::{ConnectionResult, PublishResult, TicketResult}; use crate::state::AppState; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use tauri::State; +use tauri::{Manager, State}; use tokio::sync::oneshot; // Global OAuth state storage (verifier + service per state key) @@ -407,3 +408,223 @@ mod tests { assert_eq!(deserialized.state, response.state); } } + +// ─── Webview-Based Authentication (Option C) ──────────────────────────────── + +#[derive(Debug, Serialize, Deserialize)] +pub struct WebviewAuthRequest { + pub service: String, + pub base_url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WebviewAuthResponse { + pub success: bool, + pub message: String, + pub webview_id: String, +} + +/// Open embedded browser window for user to log in. +/// After successful login, call extract_cookies_from_webview to capture session. +#[tauri::command] +pub async fn authenticate_with_webview( + service: String, + base_url: String, + app_handle: tauri::AppHandle, +) -> Result { + let webview_id = format!("{}-auth", service); + + // Open login page in embedded browser + let _credentials = crate::integrations::webview_auth::authenticate_with_webview( + app_handle, + &service, + &base_url, + ) + .await?; + + Ok(WebviewAuthResponse { + success: true, + message: format!("Login window opened. Complete authentication in the browser."), + webview_id, + }) +} + +/// Extract cookies from webview after user completes login. +/// User should call this after they've successfully logged in. +#[tauri::command] +pub async fn extract_cookies_from_webview( + service: String, + webview_id: String, + app_handle: tauri::AppHandle, + app_state: State<'_, AppState>, +) -> Result { + // Extract cookies from the webview + let cookies = crate::integrations::webview_auth::extract_cookies_from_webview( + &webview_id, + &app_handle, + &service, + ) + .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 encrypted_cookies = crate::integrations::auth::encrypt_token(&cookies_json)?; + + let token_hash = { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(cookies_json.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + // Store in database + let db = app_state + .db + .lock() + .map_err(|e| format!("Failed to lock database: {}", e))?; + + db.execute( + "INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + uuid::Uuid::now_v7().to_string(), + service, + token_hash, + encrypted_cookies, + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + None::, // Cookies don't have explicit expiry + ], + ) + .map_err(|e| format!("Failed to store cookies: {}", e))?; + + // Close the webview window + if let Some(webview) = app_handle.get_webview_window(&webview_id) { + webview + .close() + .map_err(|e| format!("Failed to close webview: {}", e))?; + } + + Ok(ConnectionResult { + success: true, + message: format!("{} authentication saved successfully", service), + }) +} + +// ─── Manual Token Authentication (Token Mode) ─────────────────────────────── + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenAuthRequest { + pub service: String, + pub token: String, + pub token_type: String, // "Bearer", "Basic", "api_token" + pub base_url: String, +} + +/// Store a manually provided token (API key, PAT, etc.) +/// This is the fallback authentication method when OAuth2 and webview don't work. +#[tauri::command] +pub async fn save_manual_token( + request: TokenAuthRequest, + app_state: State<'_, AppState>, +) -> Result { + // Validate token by testing connection + let test_result = match request.service.as_str() { + "confluence" => { + let config = crate::integrations::confluence::ConfluenceConfig { + base_url: request.base_url.clone(), + access_token: request.token.clone(), + }; + crate::integrations::confluence::test_connection(&config).await + } + "azuredevops" => { + let config = crate::integrations::azuredevops::AzureDevOpsConfig { + organization_url: request.base_url.clone(), + access_token: request.token.clone(), + project: "".to_string(), // Project not needed for connection test + }; + crate::integrations::azuredevops::test_connection(&config).await + } + "servicenow" => { + // ServiceNow uses basic auth, token is base64(username:password) + let config = crate::integrations::servicenow::ServiceNowConfig { + instance_url: request.base_url.clone(), + username: "".to_string(), // Encoded in token + password: request.token.clone(), + }; + crate::integrations::servicenow::test_connection(&config).await + } + _ => return Err(format!("Unknown service: {}", request.service)), + }; + + // If test fails, don't save the token + if let Ok(result) = &test_result { + if !result.success { + return Ok(ConnectionResult { + success: false, + message: format!( + "Token validation failed: {}. Token not saved.", + result.message + ), + }); + } + } + + // Encrypt and store token + let encrypted_token = crate::integrations::auth::encrypt_token(&request.token)?; + + let token_hash = { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(request.token.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + let db = app_state + .db + .lock() + .map_err(|e| format!("Failed to lock database: {}", e))?; + + db.execute( + "INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + uuid::Uuid::now_v7().to_string(), + request.service, + token_hash, + encrypted_token, + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + None::, + ], + ) + .map_err(|e| format!("Failed to store token: {}", e))?; + + // Log audit event + db.execute( + "INSERT INTO audit_log (id, timestamp, action, entity_type, entity_id, user_id, details) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + uuid::Uuid::now_v7().to_string(), + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + "manual_token_saved", + "credential", + request.service, + "local", + serde_json::json!({ + "token_type": request.token_type, + "token_hash": token_hash, + }) + .to_string(), + ], + ) + .map_err(|e| format!("Failed to log audit event: {}", e))?; + + Ok(ConnectionResult { + success: true, + message: format!("{} token saved and validated successfully", request.service), + }) +} diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs index 91fce5a2..0a8610a5 100644 --- a/src-tauri/src/integrations/mod.rs +++ b/src-tauri/src/integrations/mod.rs @@ -3,6 +3,7 @@ pub mod azuredevops; pub mod callback_server; pub mod confluence; pub mod servicenow; +pub mod webview_auth; use serde::{Deserialize, Serialize}; @@ -24,3 +25,23 @@ pub struct TicketResult { pub ticket_number: String, pub url: String, } + +/// Authentication method for integration services +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method")] +pub enum AuthMethod { + #[serde(rename = "oauth2")] + OAuth2 { + access_token: String, + expires_at: Option, + }, + #[serde(rename = "cookies")] + Cookies { + cookies: Vec, + }, + #[serde(rename = "token")] + Token { + token: String, + token_type: String, // "Bearer", "Basic", etc. + }, +} diff --git a/src-tauri/src/integrations/webview_auth.rs b/src-tauri/src/integrations/webview_auth.rs new file mode 100644 index 00000000..67ca2cfe --- /dev/null +++ b/src-tauri/src/integrations/webview_auth.rs @@ -0,0 +1,156 @@ +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedCredentials { + pub cookies: Vec, + pub service: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cookie { + pub name: String, + pub value: String, + pub domain: String, + pub path: String, + pub secure: bool, + pub http_only: bool, + pub expires: Option, +} + +/// Open an embedded browser window for the user to log in and extract cookies. +/// This approach works when user is off-VPN (can access web UI) but APIs require VPN. +pub async fn authenticate_with_webview( + app_handle: AppHandle, + service: &str, + base_url: &str, +) -> Result { + let login_url = match service { + "confluence" => format!("{}/login.action", base_url.trim_end_matches('/')), + "azuredevops" => { + // Azure DevOps login - user will be redirected through Microsoft SSO + format!("{}/_signin", base_url.trim_end_matches('/')) + } + "servicenow" => format!("{}/login.do", base_url.trim_end_matches('/')), + _ => return Err(format!("Unknown service: {}", service)), + }; + + tracing::info!("Opening embedded browser for {} at {}", service, login_url); + + // Create embedded webview window + let webview_label = format!("{}-auth-window", service); + let _webview = WebviewWindowBuilder::new( + &app_handle, + &webview_label, + WebviewUrl::External(login_url.parse().map_err(|e| format!("Invalid URL: {}", e))?), + ) + .title(format!("Login to {}", service)) + .inner_size(800.0, 700.0) + .resizable(true) + .center() + .build() + .map_err(|e| format!("Failed to create webview: {}", e))?; + + // Wait for user to complete login + // We'll detect this by checking if they reached a success page or dashboard + // For now, return a placeholder - actual implementation needs JS injection + + Ok(ExtractedCredentials { + cookies: vec![], + service: service.to_string(), + }) +} + +/// Extract cookies from a webview after successful login. +/// This uses Tauri's webview cookie API to get session cookies. +pub async fn extract_cookies_from_webview( + webview_label: &str, + app_handle: &AppHandle, + _service: &str, +) -> Result, String> { + let webview = app_handle + .get_webview_window(webview_label) + .ok_or_else(|| "Webview window not found".to_string())?; + + // Get all cookies from the webview + // Note: Tauri 2.x provides cookie manager via webview + // We need to use eval_script to extract cookies via JavaScript + + let cookie_script = r#" + (function() { + const cookies = document.cookie.split(';').map(c => c.trim()); + const parsed = cookies.map(cookie => { + const [nameValue, ...attrs] = cookie.split(';'); + const [name, value] = nameValue.split('='); + return { + name: name.trim(), + value: value?.trim() || '', + domain: window.location.hostname, + path: '/', + secure: window.location.protocol === 'https:', + http_only: false, + expires: null + }; + }); + return JSON.stringify(parsed); + })(); + "#; + + let _result = webview + .eval(cookie_script) + .map_err(|e| format!("Failed to extract cookies: {}", e))?; + + // Parse the JSON result + // TODO: Actually parse the cookies from the eval result + // For now, return empty - needs proper implementation + + Ok(vec![]) +} + +/// Build cookie header string for HTTP requests +pub fn cookies_to_header(cookies: &[Cookie]) -> String { + cookies + .iter() + .map(|c| format!("{}={}", c.name, c.value)) + .collect::>() + .join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cookies_to_header() { + let cookies = vec![ + Cookie { + name: "JSESSIONID".to_string(), + value: "abc123".to_string(), + domain: "example.com".to_string(), + path: "/".to_string(), + secure: true, + http_only: true, + expires: None, + }, + Cookie { + name: "auth_token".to_string(), + value: "xyz789".to_string(), + domain: "example.com".to_string(), + path: "/".to_string(), + secure: true, + http_only: false, + expires: None, + }, + ]; + + let header = cookies_to_header(&cookies); + assert_eq!(header, "JSESSIONID=abc123; auth_token=xyz789"); + } + + #[test] + fn test_empty_cookies_to_header() { + let cookies = vec![]; + let header = cookies_to_header(&cookies); + assert_eq!(header, ""); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index adebea8d..4f33f579 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -87,6 +87,9 @@ pub fn run() { commands::integrations::create_azuredevops_workitem, commands::integrations::initiate_oauth, commands::integrations::handle_oauth_callback, + commands::integrations::authenticate_with_webview, + commands::integrations::extract_cookies_from_webview, + commands::integrations::save_manual_token, // System / Settings commands::system::check_ollama_installed, commands::system::get_ollama_install_guide, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a69fa85f..b3ebd7d0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "TFTSR", - "version": "0.2.9", + "version": "0.2.10", "identifier": "com.tftsr.devops", "build": { "frontendDist": "../dist", diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index 6c7a1b07..a663e9c7 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -16,6 +16,7 @@ const buttonVariants = cva( default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, @@ -342,4 +343,54 @@ export function Separator({ ); } +// ─── RadioGroup ────────────────────────────────────────────────────────────── + +interface RadioGroupContextValue { + value: string; + onValueChange: (value: string) => void; +} + +const RadioGroupContext = React.createContext(null); + +interface RadioGroupProps { + value: string; + onValueChange: (value: string) => void; + className?: string; + children: React.ReactNode; +} + +export function RadioGroup({ value, onValueChange, className, children }: RadioGroupProps) { + return ( + +
{children}
+
+ ); +} + +interface RadioGroupItemProps extends React.InputHTMLAttributes { + value: string; +} + +export const RadioGroupItem = React.forwardRef( + ({ value, className, ...props }, ref) => { + const ctx = React.useContext(RadioGroupContext); + if (!ctx) throw new Error("RadioGroupItem must be used within RadioGroup"); + + return ( + ctx.onValueChange(value)} + {...props} + /> + ); + } +); +RadioGroupItem.displayName = "RadioGroupItem"; + export { cn }; diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 4e29bfbd..513fbbe4 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -393,3 +393,27 @@ export const testServiceNowConnectionCmd = (instanceUrl: string, credentials: Re export const testAzureDevOpsConnectionCmd = (orgUrl: string, credentials: Record) => invoke("test_azuredevops_connection", { orgUrl, credentials }); + +// ─── Webview & Token Authentication ────────────────────────────────────────── + +export interface WebviewAuthResponse { + success: boolean; + message: string; + webview_id: string; +} + +export interface TokenAuthRequest { + service: string; + token: string; + token_type: string; + base_url: string; +} + +export const authenticateWithWebviewCmd = (service: string, baseUrl: string) => + invoke("authenticate_with_webview", { service, baseUrl }); + +export const extractCookiesFromWebviewCmd = (service: string, webviewId: string) => + invoke("extract_cookies_from_webview", { service, webviewId }); + +export const saveManualTokenCmd = (request: TokenAuthRequest) => + invoke("save_manual_token", { request }); diff --git a/src/pages/Settings/Integrations.tsx b/src/pages/Settings/Integrations.tsx index d599265a..7736778b 100644 --- a/src/pages/Settings/Integrations.tsx +++ b/src/pages/Settings/Integrations.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { ExternalLink, Check, X, Loader2 } from "lucide-react"; +import { ExternalLink, Check, X, Loader2, Key, Globe, Lock } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; import { Card, CardHeader, @@ -9,14 +10,20 @@ import { Button, Input, Label, + RadioGroup, + RadioGroupItem, } from "@/components/ui"; import { initiateOauthCmd, + authenticateWithWebviewCmd, + extractCookiesFromWebviewCmd, + saveManualTokenCmd, testConfluenceConnectionCmd, testServiceNowConnectionCmd, testAzureDevOpsConnectionCmd, } from "@/lib/tauriCommands"; -import { invoke } from "@tauri-apps/api/core"; + +type AuthMode = "oauth2" | "webview" | "token"; interface IntegrationConfig { service: string; @@ -25,6 +32,10 @@ interface IntegrationConfig { projectName?: string; spaceKey?: string; connected: boolean; + authMode: AuthMode; + token?: string; + tokenType?: string; + webviewId?: string; } export default function Integrations() { @@ -34,34 +45,47 @@ export default function Integrations() { baseUrl: "", spaceKey: "", connected: false, + authMode: "webview", + tokenType: "Bearer", }, servicenow: { service: "servicenow", baseUrl: "", username: "", connected: false, + authMode: "token", + tokenType: "Basic", }, azuredevops: { service: "azuredevops", baseUrl: "", projectName: "", connected: false, + authMode: "webview", + tokenType: "Bearer", }, }); const [loading, setLoading] = useState>({}); const [testResults, setTestResults] = useState>({}); - const handleConnect = async (service: string) => { + const handleAuthModeChange = (service: string, mode: AuthMode) => { + setConfigs((prev) => ({ + ...prev, + [service]: { ...prev[service], authMode: mode, connected: false }, + })); + setTestResults((prev) => ({ ...prev, [service]: null })); + }; + + const handleConnectOAuth = async (service: string) => { setLoading((prev) => ({ ...prev, [service]: true })); try { const response = await initiateOauthCmd(service); - // Open auth URL in default browser using shell plugin + // Open auth URL in default browser await invoke("plugin:shell|open", { path: response.auth_url }); - // Mark as connected (optimistic) setConfigs((prev) => ({ ...prev, [service]: { ...prev[service], connected: true }, @@ -82,6 +106,110 @@ export default function Integrations() { } }; + const handleConnectWebview = async (service: string) => { + const config = configs[service]; + setLoading((prev) => ({ ...prev, [service]: true })); + + try { + const response = await authenticateWithWebviewCmd(service, config.baseUrl); + + setConfigs((prev) => ({ + ...prev, + [service]: { ...prev[service], webviewId: response.webview_id }, + })); + + setTestResults((prev) => ({ + ...prev, + [service]: { success: true, message: response.message + " Click 'Complete Login' when done." }, + })); + } catch (err) { + console.error("Failed to open webview:", err); + setTestResults((prev) => ({ + ...prev, + [service]: { success: false, message: String(err) }, + })); + } finally { + setLoading((prev) => ({ ...prev, [service]: false })); + } + }; + + const handleCompleteWebviewLogin = async (service: string) => { + const config = configs[service]; + if (!config.webviewId) { + setTestResults((prev) => ({ + ...prev, + [service]: { success: false, message: "No webview session found. Click 'Login via Browser' first." }, + })); + return; + } + + setLoading((prev) => ({ ...prev, [`complete-${service}`]: true })); + + try { + const result = await extractCookiesFromWebviewCmd(service, config.webviewId); + + setConfigs((prev) => ({ + ...prev, + [service]: { ...prev[service], connected: true, webviewId: undefined }, + })); + + setTestResults((prev) => ({ + ...prev, + [service]: { success: result.success, message: result.message }, + })); + } catch (err) { + console.error("Failed to extract cookies:", err); + setTestResults((prev) => ({ + ...prev, + [service]: { success: false, message: String(err) }, + })); + } finally { + setLoading((prev) => ({ ...prev, [`complete-${service}`]: false })); + } + }; + + const handleSaveToken = async (service: string) => { + const config = configs[service]; + if (!config.token) { + setTestResults((prev) => ({ + ...prev, + [service]: { success: false, message: "Please enter a token" }, + })); + return; + } + + setLoading((prev) => ({ ...prev, [`save-${service}`]: true })); + + try { + const result = await saveManualTokenCmd({ + service, + token: config.token, + token_type: config.tokenType || "Bearer", + base_url: config.baseUrl, + }); + + if (result.success) { + setConfigs((prev) => ({ + ...prev, + [service]: { ...prev[service], connected: true }, + })); + } + + setTestResults((prev) => ({ + ...prev, + [service]: result, + })); + } catch (err) { + console.error("Failed to save token:", err); + setTestResults((prev) => ({ + ...prev, + [service]: { success: false, message: String(err) }, + })); + } finally { + setLoading((prev) => ({ ...prev, [`save-${service}`]: false })); + } + }; + const handleTestConnection = async (service: string) => { setLoading((prev) => ({ ...prev, [`test-${service}`]: true })); setTestResults((prev) => ({ ...prev, [service]: null })); @@ -128,12 +256,158 @@ export default function Integrations() { })); }; + const renderAuthSection = (service: string) => { + const config = configs[service]; + const isOAuthSupported = service !== "servicenow"; // ServiceNow doesn't support OAuth2 + + return ( +
+ {/* Auth Mode Selection */} +
+ + handleAuthModeChange(service, value as AuthMode)} + > + {isOAuthSupported && ( +
+ + +
+ )} +
+ + +
+
+ + +
+
+
+ + {/* OAuth2 Mode */} + {config.authMode === "oauth2" && ( +
+

+ OAuth2 requires pre-registered application credentials. This may not work in all enterprise environments. +

+ +
+ )} + + {/* Webview Mode */} + {config.authMode === "webview" && ( +
+

+ Opens an embedded browser for you to log in normally. Works even when off-VPN. Captures session cookies for API access. +

+
+ + {config.webviewId && ( + + )} +
+
+ )} + + {/* Token Mode */} + {config.authMode === "token" && ( +
+

+ Enter a Personal Access Token (PAT), API Key, or Bearer token. Most reliable method but requires manual token generation. +

+
+ + updateConfig(service, "token", e.target.value)} + /> +

+ {service === "confluence" && "Generate at: https://id.atlassian.com/manage-profile/security/api-tokens"} + {service === "azuredevops" && "Generate at: https://dev.azure.com/{org}/_usersSettings/tokens"} + {service === "servicenow" && "Use your ServiceNow password or API key"} +

+
+ +
+ )} +
+ ); + }; + return (

Integrations

- Connect TFTSR with your existing tools and platforms via OAuth2. + Connect TFTSR with your existing tools and platforms. Choose the authentication method that works best for your environment.

@@ -145,7 +419,7 @@ export default function Integrations() { Confluence - Publish RCA documents to Confluence spaces. Requires OAuth2 authentication with Atlassian. + Publish RCA documents to Confluence spaces. Supports OAuth2, browser login, or API tokens. @@ -169,26 +443,9 @@ export default function Integrations() { />
-
- + {renderAuthSection("confluence")} +
-
- - -

- ServiceNow credentials are stored securely after first login. OAuth2 not supported. -

-
- -
- + {renderAuthSection("servicenow")} +
-
- + {renderAuthSection("azuredevops")} +
);