From 93c3f9e84bc1f27653dd6743c8b7d3e8152007f5 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Nov 2025 16:57:21 +0000 Subject: [PATCH] Add infrastructure for using multiple backends --- frontend/src-tauri/Cargo.lock | 46 ++++ frontend/src-tauri/Cargo.toml | 2 + frontend/src-tauri/src/commands/auth.rs | 128 ++++++++++++ frontend/src-tauri/src/commands/backend.rs | 27 ++- frontend/src-tauri/src/commands/connection.rs | 136 ++++++++++++ frontend/src-tauri/src/commands/mod.rs | 20 +- frontend/src-tauri/src/lib.rs | 43 +++- .../src-tauri/src/state/connection_state.rs | 45 ++++ frontend/src-tauri/src/state/mod.rs | 1 + .../src/desktop/services/apiClientSetup.ts | 125 +++++++++-- frontend/src/desktop/services/authService.ts | 196 ++++++++++++++++++ .../desktop/services/connectionModeService.ts | 121 +++++++++++ .../src/desktop/services/operationRouter.ts | 91 ++++++++ 13 files changed, 955 insertions(+), 26 deletions(-) create mode 100644 frontend/src-tauri/src/commands/auth.rs create mode 100644 frontend/src-tauri/src/commands/connection.rs create mode 100644 frontend/src-tauri/src/state/connection_state.rs create mode 100644 frontend/src-tauri/src/state/mod.rs create mode 100644 frontend/src/desktop/services/authService.ts create mode 100644 frontend/src/desktop/services/connectionModeService.ts create mode 100644 frontend/src/desktop/services/operationRouter.ts diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index d2abe9651..4d0729d9c 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2048,6 +2048,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3941,6 +3951,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" name = "stirling-pdf" version = "0.1.0" dependencies = [ + "keyring", "log", "reqwest 0.11.27", "serde", @@ -3951,6 +3962,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-shell", "tauri-plugin-single-instance", + "tauri-plugin-store", "tokio", ] @@ -4352,6 +4364,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.9.1" @@ -4585,9 +4613,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2 0.6.1", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -5951,6 +5991,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.2" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index e14bbaaee..15cc11be4 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,5 +29,7 @@ tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" tauri-plugin-single-instance = "2.0.1" +tauri-plugin-store = "2.1.0" +keyring = "3.6.1" tokio = { version = "1.0", features = ["time"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs new file mode 100644 index 000000000..a1e3e0ea0 --- /dev/null +++ b/frontend/src-tauri/src/commands/auth.rs @@ -0,0 +1,128 @@ +use keyring::Entry; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +const STORE_FILE: &str = "connection.json"; +const USER_INFO_KEY: &str = "user_info"; +const KEYRING_SERVICE: &str = "stirling-pdf"; +const KEYRING_TOKEN_KEY: &str = "auth-token"; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserInfo { + pub username: String, + pub email: Option, +} + +fn get_keyring_entry() -> Result { + Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY) + .map_err(|e| format!("Failed to access keyring: {}", e)) +} + +#[tauri::command] +pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> { + log::info!("Saving auth token to keyring"); + + let entry = get_keyring_entry()?; + + entry + .set_password(&token) + .map_err(|e| format!("Failed to save token to keyring: {}", e))?; + + log::info!("Auth token saved successfully"); + Ok(()) +} + +#[tauri::command] +pub async fn get_auth_token(_app_handle: AppHandle) -> Result, String> { + log::debug!("Retrieving auth token from keyring"); + + let entry = get_keyring_entry()?; + + match entry.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("Failed to retrieve token: {}", e)), + } +} + +#[tauri::command] +pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> { + log::info!("Clearing auth token from keyring"); + + let entry = get_keyring_entry()?; + + // Delete the token - ignore error if it doesn't exist + match entry.delete_credential() { + Ok(_) => { + log::info!("Auth token cleared successfully"); + Ok(()) + } + Err(keyring::Error::NoEntry) => { + log::info!("Auth token was already cleared"); + Ok(()) + } + Err(e) => Err(format!("Failed to clear token: {}", e)), + } +} + +#[tauri::command] +pub async fn save_user_info( + app_handle: AppHandle, + username: String, + email: Option, +) -> Result<(), String> { + log::info!("Saving user info for: {}", username); + + let user_info = UserInfo { username, email }; + + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + store.set( + USER_INFO_KEY, + serde_json::to_value(&user_info) + .map_err(|e| format!("Failed to serialize user info: {}", e))?, + ); + + store + .save() + .map_err(|e| format!("Failed to save store: {}", e))?; + + log::info!("User info saved successfully"); + Ok(()) +} + +#[tauri::command] +pub async fn get_user_info(app_handle: AppHandle) -> Result, String> { + log::debug!("Retrieving user info"); + + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + let user_info: Option = store + .get(USER_INFO_KEY) + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + Ok(user_info) +} + +#[tauri::command] +pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> { + log::info!("Clearing user info"); + + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + store.delete(USER_INFO_KEY); + + store + .save() + .map_err(|e| format!("Failed to save store: {}", e))?; + + log::info!("User info cleared successfully"); + Ok(()) +} diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index c7bce50f7..ed0133873 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -3,6 +3,7 @@ use tauri::Manager; use std::sync::Mutex; use std::path::PathBuf; use crate::utils::add_log; +use crate::state::connection_state::{AppConnectionState, ConnectionMode}; // Store backend process handle globally static BACKEND_PROCESS: Mutex> = Mutex::new(None); @@ -308,9 +309,31 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver Result { +pub async fn start_backend( + app: tauri::AppHandle, + connection_state: tauri::State<'_, AppConnectionState>, +) -> Result { add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string()); - + + // Check connection mode + let mode = { + let state = connection_state.0.lock().map_err(|e| { + let error_msg = format!("❌ Failed to access connection state: {}", e); + add_log(error_msg.clone()); + error_msg + })?; + state.mode.clone() + }; + + match mode { + ConnectionMode::Offline => { + add_log("🔌 Running in Offline mode - starting local backend".to_string()); + } + ConnectionMode::Server => { + add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string()); + } + } + // Check if backend is already running or starting if let Err(msg) = check_backend_status() { return Ok(msg); diff --git a/frontend/src-tauri/src/commands/connection.rs b/frontend/src-tauri/src/commands/connection.rs new file mode 100644 index 000000000..0836d537c --- /dev/null +++ b/frontend/src-tauri/src/commands/connection.rs @@ -0,0 +1,136 @@ +use crate::state::connection_state::{ + AppConnectionState, + ConnectionMode, + ServerConfig, +}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tauri::{AppHandle, State}; +use tauri_plugin_store::StoreExt; + +const STORE_FILE: &str = "connection.json"; +const FIRST_LAUNCH_KEY: &str = "setup_completed"; +const CONNECTION_MODE_KEY: &str = "connection_mode"; +const SERVER_CONFIG_KEY: &str = "server_config"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ConnectionConfig { + pub mode: ConnectionMode, + pub server_config: Option, +} + +#[tauri::command] +pub async fn get_connection_config( + app_handle: AppHandle, + state: State<'_, AppConnectionState>, +) -> Result { + // Try to load from store + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + let mode = store + .get(CONNECTION_MODE_KEY) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or(ConnectionMode::Offline); + + let server_config: Option = store + .get(SERVER_CONFIG_KEY) + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + // Update in-memory state + if let Ok(mut conn_state) = state.0.lock() { + conn_state.mode = mode.clone(); + conn_state.server_config = server_config.clone(); + } + + Ok(ConnectionConfig { + mode, + server_config, + }) +} + +#[tauri::command] +pub async fn set_connection_mode( + app_handle: AppHandle, + state: State<'_, AppConnectionState>, + mode: ConnectionMode, + server_config: Option, +) -> Result<(), String> { + log::info!("Setting connection mode: {:?}", mode); + + // Update in-memory state + if let Ok(mut conn_state) = state.0.lock() { + conn_state.mode = mode.clone(); + conn_state.server_config = server_config.clone(); + } + + // Save to store + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + store.set( + CONNECTION_MODE_KEY, + serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?, + ); + + if let Some(config) = &server_config { + store.set( + SERVER_CONFIG_KEY, + serde_json::to_value(config) + .map_err(|e| format!("Failed to serialize config: {}", e))?, + ); + } else { + store.delete(SERVER_CONFIG_KEY); + } + + // Mark setup as completed + store.set(FIRST_LAUNCH_KEY, serde_json::json!(true)); + + store + .save() + .map_err(|e| format!("Failed to save store: {}", e))?; + + log::info!("Connection mode saved successfully"); + Ok(()) +} + +#[tauri::command] +pub async fn test_server_connection(url: String) -> Result { + log::info!("Testing connection to: {}", url); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + // Try to hit the health/status endpoint + let health_url = format!("{}/api/v1/info/status", url.trim_end_matches('/')); + + match client.get(&health_url).send().await { + Ok(response) => { + let is_ok = response.status().is_success(); + log::info!("Server connection test result: {}", is_ok); + Ok(is_ok) + } + Err(e) => { + log::warn!("Server connection test failed: {}", e); + Err(format!("Connection failed: {}", e)) + } + } +} + +#[tauri::command] +pub async fn is_first_launch(app_handle: AppHandle) -> Result { + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + let setup_completed = store + .get(FIRST_LAUNCH_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Ok(!setup_completed) +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index f21bf8042..8471457ee 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -1,7 +1,23 @@ pub mod backend; pub mod health; pub mod files; +pub mod connection; +pub mod auth; -pub use backend::{start_backend, cleanup_backend}; +pub use backend::{cleanup_backend, start_backend}; pub use health::check_backend_health; -pub use files::{get_opened_files, clear_opened_files, add_opened_file}; +pub use files::{add_opened_file, clear_opened_files, get_opened_files}; +pub use connection::{ + get_connection_config, + is_first_launch, + set_connection_mode, + test_server_connection, +}; +pub use auth::{ + clear_auth_token, + clear_user_info, + get_auth_token, + get_user_info, + save_auth_token, + save_user_info, +}; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 9a07845e1..ae316d424 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,9 +1,28 @@ -use tauri::{RunEvent, WindowEvent, Emitter, Manager}; +use tauri::{Manager, RunEvent, WindowEvent, Emitter}; mod utils; mod commands; +mod state; -use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file}; +use commands::{ + add_opened_file, + check_backend_health, + cleanup_backend, + clear_auth_token, + clear_opened_files, + clear_user_info, + get_auth_token, + get_connection_config, + get_opened_files, + get_user_info, + is_first_launch, + save_auth_token, + save_user_info, + set_connection_mode, + start_backend, + test_server_connection, +}; +use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -11,6 +30,8 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_store::Builder::new().build()) + .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { // This callback runs when a second instance tries to start add_log(format!("📂 Second instance detected with args: {:?}", args)); @@ -39,7 +60,23 @@ pub fn run() { add_log("🔍 DEBUG: Setup completed".to_string()); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs]) + .invoke_handler(tauri::generate_handler![ + start_backend, + check_backend_health, + get_opened_files, + clear_opened_files, + get_tauri_logs, + get_connection_config, + set_connection_mode, + test_server_connection, + is_first_launch, + save_auth_token, + get_auth_token, + clear_auth_token, + save_user_info, + get_user_info, + clear_user_info, + ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { diff --git a/frontend/src-tauri/src/state/connection_state.rs b/frontend/src-tauri/src/state/connection_state.rs new file mode 100644 index 000000000..e6a924956 --- /dev/null +++ b/frontend/src-tauri/src/state/connection_state.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ConnectionMode { + Offline, + Server, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ServerType { + SaaS, + SelfHosted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub url: String, + pub server_type: ServerType, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionState { + pub mode: ConnectionMode, + pub server_config: Option, +} + +impl Default for ConnectionState { + fn default() -> Self { + Self { + mode: ConnectionMode::Offline, + server_config: None, + } + } +} + +pub struct AppConnectionState(pub Mutex); + +impl Default for AppConnectionState { + fn default() -> Self { + Self(Mutex::new(ConnectionState::default())) + } +} diff --git a/frontend/src-tauri/src/state/mod.rs b/frontend/src-tauri/src/state/mod.rs new file mode 100644 index 000000000..4b8c86c60 --- /dev/null +++ b/frontend/src-tauri/src/state/mod.rs @@ -0,0 +1 @@ +pub mod connection_state; diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index eea7521a6..e8c58b5a8 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -1,44 +1,131 @@ -import { AxiosInstance } from 'axios'; +import { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; import { alert } from '@app/components/toast'; import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { createBackendNotReadyError } from '@app/constants/backendErrors'; +import { operationRouter } from '@app/services/operationRouter'; +import { authService } from '@app/services/authService'; +import { connectionModeService } from '@app/services/connectionModeService'; import i18n from '@app/i18n'; const BACKEND_TOAST_COOLDOWN_MS = 4000; let lastBackendToast = 0; +// Extend Axios config to include our custom properties +interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig { + operationName?: string; + skipBackendReadyCheck?: boolean; + _retry?: boolean; +} + /** * Desktop-specific API interceptors * - Reuses the core interceptors - * - Blocks API calls while the bundled backend is still starting and shows - * a friendly toast for user-initiated requests (non-GET) + * - Dynamically sets base URL based on connection mode + * - Adds auth token for remote server requests + * - Blocks API calls while the bundled backend is still starting + * - Handles auth token refresh on 401 errors */ export function setupApiInterceptors(client: AxiosInstance): void { coreSetup(client); + // Request interceptor: Set base URL and auth headers dynamically client.interceptors.request.use( - (config) => { - const skipCheck = config?.skipBackendReadyCheck === true; - if (skipCheck || tauriBackendService.isBackendHealthy()) { - return config; + async (config: InternalAxiosRequestConfig) => { + const extendedConfig = config as ExtendedAxiosRequestConfig; + + // Get the operation name from config if provided + const operation = extendedConfig.operationName; + + // Get the appropriate base URL for this operation + const baseUrl = await operationRouter.getBaseUrl(operation); + + // Build the full URL + if (extendedConfig.url && !extendedConfig.url.startsWith('http')) { + extendedConfig.url = `${baseUrl}${extendedConfig.url}`; } - const method = (config.method || 'get').toLowerCase(); - if (method !== 'get') { - const now = Date.now(); - if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) { - lastBackendToast = now; - alert({ - alertType: 'error', - title: i18n.t('backendHealth.offline', 'Backend Offline'), - body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'), - isPersistentPopup: false, - }); + // Add auth token for remote requests + const isRemote = await operationRouter.isRemoteMode(); + if (isRemote) { + const token = await authService.getAuthToken(); + if (token) { + extendedConfig.headers.Authorization = `Bearer ${token}`; } } - return Promise.reject(createBackendNotReadyError()); + // Backend readiness check (for local backend) + const skipCheck = extendedConfig.skipBackendReadyCheck === true; + const isOffline = await operationRouter.isOfflineMode(); + + if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) { + const method = (extendedConfig.method || 'get').toLowerCase(); + if (method !== 'get') { + const now = Date.now(); + if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) { + lastBackendToast = now; + alert({ + alertType: 'error', + title: i18n.t('backendHealth.offline', 'Backend Offline'), + body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'), + isPersistentPopup: false, + }); + } + } + return Promise.reject(createBackendNotReadyError()); + } + + return extendedConfig; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor: Handle auth errors + client.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config as ExtendedAxiosRequestConfig; + + // Handle 401 Unauthorized - try to refresh token + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + const isRemote = await operationRouter.isRemoteMode(); + if (isRemote) { + const serverConfig = await connectionModeService.getServerConfig(); + if (serverConfig) { + const refreshed = await authService.refreshToken(serverConfig.url); + if (refreshed) { + // Retry the original request with new token + const token = await authService.getAuthToken(); + if (token) { + originalRequest.headers.Authorization = `Bearer ${token}`; + } + return client(originalRequest); + } + } + } + + // Refresh failed or not in remote mode - user needs to login again + alert({ + alertType: 'error', + title: i18n.t('auth.sessionExpired', 'Session Expired'), + body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'), + isPersistentPopup: false, + }); + } + + // Handle 403 Forbidden - unauthorized access + if (error.response?.status === 403) { + alert({ + alertType: 'error', + title: i18n.t('auth.accessDenied', 'Access Denied'), + body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'), + isPersistentPopup: false, + }); + } + + return Promise.reject(error); } ); } diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts new file mode 100644 index 000000000..c13becabf --- /dev/null +++ b/frontend/src/desktop/services/authService.ts @@ -0,0 +1,196 @@ +import { invoke } from '@tauri-apps/api/core'; +import axios from 'axios'; + +export interface UserInfo { + username: string; + email?: string; +} + +export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing'; + +export class AuthService { + private static instance: AuthService; + private authStatus: AuthStatus = 'unauthenticated'; + private userInfo: UserInfo | null = null; + private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>(); + + static getInstance(): AuthService { + if (!AuthService.instance) { + AuthService.instance = new AuthService(); + } + return AuthService.instance; + } + + subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void { + this.authListeners.add(listener); + // Immediately notify new listener of current state + listener(this.authStatus, this.userInfo); + return () => { + this.authListeners.delete(listener); + }; + } + + private notifyListeners() { + this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo)); + } + + private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) { + this.authStatus = status; + this.userInfo = userInfo; + this.notifyListeners(); + } + + async login(serverUrl: string, username: string, password: string): Promise { + try { + console.log('Logging in to:', serverUrl); + + // Call the server's login endpoint + const response = await axios.post(`${serverUrl}/api/v1/auth/login`, { + username, + password, + }); + + const { token, username: returnedUsername, email } = response.data; + + // Save the token to keyring + await invoke('save_auth_token', { token }); + + // Save user info to store + await invoke('save_user_info', { + username: returnedUsername || username, + email, + }); + + const userInfo: UserInfo = { + username: returnedUsername || username, + email, + }; + + this.setAuthStatus('authenticated', userInfo); + + console.log('Login successful'); + return userInfo; + } catch (error) { + console.error('Login failed:', error); + this.setAuthStatus('unauthenticated', null); + + if (axios.isAxiosError(error)) { + if (error.response?.status === 401) { + throw new Error('Invalid username or password'); + } else if (error.response?.status === 403) { + throw new Error('Access denied'); + } else if (error.code === 'ERR_NETWORK') { + throw new Error('Network error - could not connect to server'); + } + } + + throw new Error('Login failed. Please try again.'); + } + } + + async logout(): Promise { + try { + console.log('Logging out'); + + // Clear token from keyring + await invoke('clear_auth_token'); + + // Clear user info from store + await invoke('clear_user_info'); + + this.setAuthStatus('unauthenticated', null); + + console.log('Logged out successfully'); + } catch (error) { + console.error('Error during logout:', error); + // Still set status to unauthenticated even if clear fails + this.setAuthStatus('unauthenticated', null); + } + } + + async getAuthToken(): Promise { + try { + const token = await invoke('get_auth_token'); + return token || null; + } catch (error) { + console.error('Failed to get auth token:', error); + return null; + } + } + + async isAuthenticated(): Promise { + const token = await this.getAuthToken(); + return token !== null; + } + + async getUserInfo(): Promise { + if (this.userInfo) { + return this.userInfo; + } + + try { + const userInfo = await invoke('get_user_info'); + this.userInfo = userInfo; + return userInfo; + } catch (error) { + console.error('Failed to get user info:', error); + return null; + } + } + + async refreshToken(serverUrl: string): Promise { + try { + console.log('Refreshing auth token'); + this.setAuthStatus('refreshing', this.userInfo); + + const currentToken = await this.getAuthToken(); + if (!currentToken) { + this.setAuthStatus('unauthenticated', null); + return false; + } + + // Call the server's refresh endpoint + const response = await axios.post( + `${serverUrl}/api/v1/auth/refresh`, + {}, + { + headers: { + Authorization: `Bearer ${currentToken}`, + }, + } + ); + + const { token } = response.data; + + // Save the new token + await invoke('save_auth_token', { token }); + + const userInfo = await this.getUserInfo(); + this.setAuthStatus('authenticated', userInfo); + + console.log('Token refreshed successfully'); + return true; + } catch (error) { + console.error('Token refresh failed:', error); + this.setAuthStatus('unauthenticated', null); + + // Clear stored credentials on refresh failure + await this.logout(); + + return false; + } + } + + async initializeAuthState(): Promise { + const token = await this.getAuthToken(); + const userInfo = await this.getUserInfo(); + + if (token && userInfo) { + this.setAuthStatus('authenticated', userInfo); + } else { + this.setAuthStatus('unauthenticated', null); + } + } +} + +export const authService = AuthService.getInstance(); diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts new file mode 100644 index 000000000..17f496424 --- /dev/null +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -0,0 +1,121 @@ +import { invoke } from '@tauri-apps/api/core'; + +export type ConnectionMode = 'offline' | 'server'; +export type ServerType = 'saas' | 'selfhosted'; + +export interface ServerConfig { + url: string; + server_type: ServerType; +} + +export interface ConnectionConfig { + mode: ConnectionMode; + server_config: ServerConfig | null; +} + +export class ConnectionModeService { + private static instance: ConnectionModeService; + private currentConfig: ConnectionConfig | null = null; + private configLoadedOnce = false; + private modeListeners = new Set<(config: ConnectionConfig) => void>(); + + static getInstance(): ConnectionModeService { + if (!ConnectionModeService.instance) { + ConnectionModeService.instance = new ConnectionModeService(); + } + return ConnectionModeService.instance; + } + + async getCurrentConfig(): Promise { + if (!this.configLoadedOnce) { + await this.loadConfig(); + } + return this.currentConfig || { mode: 'offline', server_config: null }; + } + + async getCurrentMode(): Promise { + const config = await this.getCurrentConfig(); + return config.mode; + } + + async getServerConfig(): Promise { + const config = await this.getCurrentConfig(); + return config.server_config; + } + + subscribeToModeChanges(listener: (config: ConnectionConfig) => void): () => void { + this.modeListeners.add(listener); + return () => { + this.modeListeners.delete(listener); + }; + } + + private notifyListeners() { + if (this.currentConfig) { + this.modeListeners.forEach(listener => listener(this.currentConfig!)); + } + } + + private async loadConfig(): Promise { + try { + const config = await invoke('get_connection_config'); + this.currentConfig = config; + this.configLoadedOnce = true; + } catch (error) { + console.error('Failed to load connection config:', error); + // Default to offline mode on error + this.currentConfig = { mode: 'offline', server_config: null }; + this.configLoadedOnce = true; + } + } + + async switchToOffline(): Promise { + console.log('Switching to offline mode'); + + await invoke('set_connection_mode', { + mode: 'offline', + serverConfig: null, + }); + + this.currentConfig = { mode: 'offline', server_config: null }; + this.notifyListeners(); + + console.log('Switched to offline mode successfully'); + } + + async switchToServer(serverConfig: ServerConfig): Promise { + console.log('Switching to server mode:', serverConfig); + + await invoke('set_connection_mode', { + mode: 'server', + serverConfig, + }); + + this.currentConfig = { mode: 'server', server_config: serverConfig }; + this.notifyListeners(); + + console.log('Switched to server mode successfully'); + } + + async testConnection(url: string): Promise { + try { + const result = await invoke('test_server_connection', { url }); + return result; + } catch (error) { + console.error('Connection test failed:', error); + return false; + } + } + + async isFirstLaunch(): Promise { + try { + const result = await invoke('is_first_launch'); + return result; + } catch (error) { + console.error('Failed to check first launch:', error); + return false; + } + } +} + +export const connectionModeService = ConnectionModeService.getInstance(); diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts new file mode 100644 index 000000000..d7acddd3d --- /dev/null +++ b/frontend/src/desktop/services/operationRouter.ts @@ -0,0 +1,91 @@ +import { connectionModeService, ConnectionMode } from './connectionModeService'; + +export type ExecutionTarget = 'local' | 'remote'; + +export class OperationRouter { + private static instance: OperationRouter; + + static getInstance(): OperationRouter { + if (!OperationRouter.instance) { + OperationRouter.instance = new OperationRouter(); + } + return OperationRouter.instance; + } + + /** + * Determines where an operation should execute + * @param operation - The operation name (for future operation classification) + * @returns 'local' or 'remote' + */ + async getExecutionTarget(operation?: string): Promise { + const mode = await connectionModeService.getCurrentMode(); + + // Current implementation: simple mode-based routing + if (mode === 'offline') { + return 'local'; + } + + // In server mode, currently all operations go to remote + // Future enhancement: check if operation is "simple" and route to local if so + // Example future logic: + // if (mode === 'server' && operation && this.isSimpleOperation(operation)) { + // return 'local'; + // } + + return 'remote'; + } + + /** + * Gets the base URL for an operation based on execution target + * @param operation - The operation name (for future operation classification) + * @returns Base URL for API calls + */ + async getBaseUrl(operation?: string): Promise { + const target = await this.getExecutionTarget(operation); + + if (target === 'local') { + return 'http://localhost:8080'; + } + + // Remote: get from server config + const serverConfig = await connectionModeService.getServerConfig(); + if (!serverConfig) { + console.warn('No server config found, falling back to local'); + return 'http://localhost:8080'; + } + + return serverConfig.url; + } + + /** + * Checks if we're currently in remote mode + */ + async isRemoteMode(): Promise { + const mode = await connectionModeService.getCurrentMode(); + return mode === 'server'; + } + + /** + * Checks if we're currently in offline mode + */ + async isOfflineMode(): Promise { + const mode = await connectionModeService.getCurrentMode(); + return mode === 'offline'; + } + + // Future enhancement: operation classification + // private isSimpleOperation(operation: string): boolean { + // const simpleOperations = [ + // 'rotate', + // 'merge', + // 'split', + // 'extract-pages', + // 'remove-pages', + // 'reorder-pages', + // 'metadata', + // ]; + // return simpleOperations.includes(operation); + // } +} + +export const operationRouter = OperationRouter.getInstance();