mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-30 20:06:30 +01:00
Allow login to SaaS for desktop instead of offline mode (#4941)
# Description of Changes Makes the desktop options to sign in with your Stirling account, or sign into self-hosted: <img width="608" height="456" alt="image" src="https://github.com/user-attachments/assets/a49988ab-db3f-4333-b242-790aee5c07c6" /> The first option still runs everything locally, just enforces that you've signed in for now. Future work will enable sending operations that can't be run locally to the server.
This commit is contained in:
parent
e1a879a5f6
commit
e8e98128d2
2
.github/workflows/tauri-build.yml
vendored
2
.github/workflows/tauri-build.yml
vendored
@ -312,6 +312,8 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
|
||||
@ -5623,15 +5623,23 @@
|
||||
"description": "Enter credentials"
|
||||
},
|
||||
"mode": {
|
||||
"offline": {
|
||||
"title": "Use Offline",
|
||||
"description": "Run locally without an internet connection"
|
||||
"saas": {
|
||||
"title": "Stirling Cloud",
|
||||
"description": "Sign in with your Stirling account"
|
||||
},
|
||||
"server": {
|
||||
"title": "Connect to Server",
|
||||
"description": "Connect to a remote Stirling PDF server"
|
||||
"selfhosted": {
|
||||
"title": "Self-Hosted Server",
|
||||
"description": "Connect to your own Stirling PDF server"
|
||||
}
|
||||
},
|
||||
"saas": {
|
||||
"title": "Sign in to Stirling",
|
||||
"subtitle": "Sign in with your Stirling account"
|
||||
},
|
||||
"selfhosted": {
|
||||
"title": "Sign in to Server",
|
||||
"subtitle": "Enter your server credentials"
|
||||
},
|
||||
"server": {
|
||||
"title": "Connect to Server",
|
||||
"subtitle": "Enter your self-hosted server URL",
|
||||
@ -5673,16 +5681,12 @@
|
||||
"connection": {
|
||||
"title": "Connection Mode",
|
||||
"mode": {
|
||||
"offline": "Offline",
|
||||
"server": "Server"
|
||||
"saas": "Stirling Cloud",
|
||||
"selfhosted": "Self-Hosted"
|
||||
},
|
||||
"server": "Server",
|
||||
"user": "Logged in as",
|
||||
"switchToServer": "Connect to Server",
|
||||
"switchToOffline": "Switch to Offline",
|
||||
"logout": "Logout",
|
||||
"selectServer": "Select Server",
|
||||
"login": "Login"
|
||||
"logout": "Log Out"
|
||||
},
|
||||
"general": {
|
||||
"title": "General",
|
||||
|
||||
@ -127,7 +127,7 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Response types for Spring Boot login
|
||||
// Response types for Spring Boot login (self-hosted)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootSession {
|
||||
access_token: String,
|
||||
@ -145,6 +145,24 @@ struct SpringBootLoginResponse {
|
||||
user: SpringBootUser,
|
||||
}
|
||||
|
||||
// Response types for Supabase login (SaaS)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseUserMetadata {
|
||||
full_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseUser {
|
||||
email: Option<String>,
|
||||
user_metadata: Option<SupabaseUserMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseLoginResponse {
|
||||
access_token: String,
|
||||
user: SupabaseUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
@ -153,7 +171,7 @@ pub struct LoginResponse {
|
||||
}
|
||||
|
||||
/// Login command - makes HTTP request from Rust to bypass CORS
|
||||
/// Supports Spring Boot authentication (self-hosted)
|
||||
/// Supports both Supabase authentication (SaaS) and Spring Boot authentication (self-hosted)
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
server_url: String,
|
||||
@ -162,54 +180,124 @@ pub async fn login(
|
||||
) -> Result<LoginResponse, String> {
|
||||
log::info!("Login attempt for user: {} to server: {}", username, server_url);
|
||||
|
||||
// Build login URL
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Login URL: {}", login_url);
|
||||
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
|
||||
// Compare against the configured SaaS server URL from environment
|
||||
let saas_server_url = env!("VITE_SAAS_SERVER_URL");
|
||||
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
|
||||
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
|
||||
|
||||
// Create HTTP client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Make login request
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
if is_supabase {
|
||||
// Supabase authentication flow
|
||||
let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/'));
|
||||
|
||||
// Supabase public API key from environment variable (required at compile time)
|
||||
// Set VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY before building
|
||||
let supabase_key = env!("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY");
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"email": username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.header("Content-Type", "application/json;charset=UTF-8")
|
||||
.header("apikey", supabase_key)
|
||||
.header("Authorization", format!("Bearer {}", supabase_key))
|
||||
.header("X-Client-Info", "supabase-js-web/2.58.0")
|
||||
.header("X-Supabase-Api-Version", "2024-01-01")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Supabase login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 400 || status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Supabase response format
|
||||
let login_response: SupabaseLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Supabase response: {}", e))?;
|
||||
|
||||
let email = login_response.user.email.clone();
|
||||
let username = login_response.user.user_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.full_name.clone())
|
||||
.or_else(|| email.clone())
|
||||
.unwrap_or_else(|| username);
|
||||
|
||||
log::info!("Supabase login successful for user: {}", username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.access_token,
|
||||
username,
|
||||
email,
|
||||
})
|
||||
} else {
|
||||
// Spring Boot authentication flow
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Spring Boot login URL: {}", login_url);
|
||||
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Spring Boot login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?;
|
||||
|
||||
log::info!("Spring Boot login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
log::info!("Login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
|
||||
@ -349,11 +349,11 @@ pub async fn start_backend(
|
||||
};
|
||||
|
||||
match mode {
|
||||
ConnectionMode::Offline => {
|
||||
add_log("🔌 Running in Offline mode - starting local backend".to_string());
|
||||
ConnectionMode::SaaS => {
|
||||
add_log("☁️ Running in SaaS mode - starting local backend".to_string());
|
||||
}
|
||||
ConnectionMode::Server => {
|
||||
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
|
||||
ConnectionMode::SelfHosted => {
|
||||
add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ pub async fn get_connection_config(
|
||||
let mode = store
|
||||
.get(CONNECTION_MODE_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or(ConnectionMode::Offline);
|
||||
.unwrap_or(ConnectionMode::SaaS);
|
||||
|
||||
let server_config: Option<ServerConfig> = store
|
||||
.get(SERVER_CONFIG_KEY)
|
||||
@ -109,3 +109,22 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
||||
|
||||
Ok(!setup_completed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
|
||||
log::info!("Resetting setup completion flag");
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
// Reset setup completion flag to force SetupWizard on next launch
|
||||
store.set(FIRST_LAUNCH_KEY, serde_json::json!(false));
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("Setup completion flag reset successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ pub use files::{add_opened_file, clear_opened_files, get_opened_files};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
set_connection_mode,
|
||||
};
|
||||
pub use auth::{
|
||||
|
||||
@ -19,6 +19,7 @@ use commands::{
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
login,
|
||||
reset_setup_completion,
|
||||
save_auth_token,
|
||||
save_user_info,
|
||||
set_connection_mode,
|
||||
@ -85,6 +86,7 @@ pub fn run() {
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
check_backend_health,
|
||||
login,
|
||||
save_auth_token,
|
||||
|
||||
@ -4,13 +4,6 @@ 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,
|
||||
}
|
||||
@ -18,7 +11,6 @@ pub enum ServerType {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub url: String,
|
||||
pub server_type: ServerType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -30,7 +22,7 @@ pub struct ConnectionState {
|
||||
impl Default for ConnectionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ConnectionMode::Offline,
|
||||
mode: ConnectionMode::SaaS,
|
||||
server_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,23 +17,23 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null);
|
||||
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
|
||||
|
||||
// Load connection mode on mount
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
// Initialize backend health monitoring for server mode
|
||||
// Initialize backend health monitoring for self-hosted mode
|
||||
useEffect(() => {
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
|
||||
console.log('[AppProviders] Initializing external backend monitoring for server mode');
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
|
||||
void tauriBackendService.initializeExternalBackend();
|
||||
}
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Only start bundled backend if in offline mode and setup is complete
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
|
||||
// Only start bundled backend if in SaaS mode (local backend) and setup is complete
|
||||
// Self-hosted mode connects to remote server so doesn't need local backend
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
useBackendInitializer(shouldStartBackend);
|
||||
|
||||
// Show setup wizard on first launch
|
||||
@ -51,8 +51,23 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
// Reload the page to reinitialize with new connection config
|
||||
onComplete={async () => {
|
||||
// Wait for backend to become healthy before reloading
|
||||
// This prevents reloading mid-startup which would interrupt the backend
|
||||
const maxWaitTime = 60000; // 60 seconds max
|
||||
const checkInterval = 1000; // Check every second
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
// If we timeout, reload anyway
|
||||
console.warn('[AppProviders] Backend health check timeout, reloading anyway...');
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,24 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
|
||||
import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
connectionModeService,
|
||||
ConnectionConfig,
|
||||
ServerConfig,
|
||||
} from '@app/services/connectionModeService';
|
||||
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
export const ConnectionSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<ConnectionConfig | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showServerModal, setShowServerModal] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [newServerConfig, setNewServerConfig] = useState<ServerConfig | null>(null);
|
||||
|
||||
// Load current config on mount
|
||||
useEffect(() => {
|
||||
@ -26,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(currentConfig);
|
||||
|
||||
if (currentConfig.mode === 'server') {
|
||||
if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
}
|
||||
@ -35,80 +26,26 @@ export const ConnectionSettings: React.FC = () => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleSwitchToOffline = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await connectionModeService.switchToOffline();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Reload the page to start the local backend
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to switch to offline:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToServer = () => {
|
||||
setShowServerModal(true);
|
||||
};
|
||||
|
||||
const handleServerConfigSubmit = (serverConfig: ServerConfig) => {
|
||||
setNewServerConfig(serverConfig);
|
||||
setShowServerModal(false);
|
||||
setShowLoginModal(true);
|
||||
};
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
if (!newServerConfig) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Login
|
||||
await authService.login(newServerConfig.url, username, password);
|
||||
|
||||
// Switch to server mode
|
||||
await connectionModeService.switchToServer(newServerConfig);
|
||||
|
||||
// Reload config and user info
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
|
||||
setShowLoginModal(false);
|
||||
setNewServerConfig(null);
|
||||
|
||||
// Reload the page to stop local backend and initialize external backend monitoring
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error; // Let LoginForm handle the error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await authService.logout();
|
||||
|
||||
// Switch to offline mode
|
||||
await connectionModeService.switchToOffline();
|
||||
// Switch to SaaS mode
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
||||
|
||||
// Reset setup completion to force login screen on reload
|
||||
await connectionModeService.resetSetupCompletion();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Reload the page to clear all state and reconnect to local backend
|
||||
// Clear URL to home page before reload so we don't return to settings after re-login
|
||||
window.history.replaceState({}, '', '/');
|
||||
|
||||
// Reload the page to clear all state and show login screen
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
@ -127,21 +64,21 @@ export const ConnectionSettings: React.FC = () => {
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
|
||||
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light">
|
||||
{config.mode === 'offline'
|
||||
? t('settings.connection.mode.offline', 'Offline')
|
||||
: t('settings.connection.mode.server', 'Server')}
|
||||
<Badge color={config.mode === 'saas' ? 'blue' : 'green'} variant="light">
|
||||
{config.mode === 'saas'
|
||||
? t('settings.connection.mode.saas', 'Stirling Cloud')
|
||||
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{config.mode === 'server' && config.server_config && (
|
||||
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('settings.connection.server', 'Server')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{config.server_config.url}
|
||||
{config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
<Group mt="md">
|
||||
{config.mode === 'offline' ? (
|
||||
<Button onClick={handleSwitchToServer} disabled={loading}>
|
||||
{t('settings.connection.switchToServer', 'Connect to Server')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleSwitchToOffline} variant="default" disabled={loading}>
|
||||
{t('settings.connection.switchToOffline', 'Switch to Offline')}
|
||||
</Button>
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Logout')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Log Out')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Server selection modal */}
|
||||
<Modal
|
||||
opened={showServerModal}
|
||||
onClose={() => setShowServerModal(false)}
|
||||
title={t('settings.connection.selectServer', 'Select Server')}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
|
||||
</Modal>
|
||||
|
||||
{/* Login modal */}
|
||||
<Modal
|
||||
opened={showLoginModal}
|
||||
onClose={() => {
|
||||
setShowLoginModal(false);
|
||||
setNewServerConfig(null);
|
||||
}}
|
||||
title={t('settings.connection.login', 'Login')}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{newServerConfig && (
|
||||
<LoginForm
|
||||
serverUrl={newServerConfig.url}
|
||||
onLogin={handleLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Mini server selection component for settings
|
||||
const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
|
||||
|
||||
if (!url) {
|
||||
setError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const isReachable = await connectionModeService.testConnection(url);
|
||||
|
||||
if (!isReachable) {
|
||||
setError(t('setup.server.error.unreachable', 'Could not connect to server'));
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
url,
|
||||
server_type: serverType,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed'));
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
|
||||
<Stack gap="xs">
|
||||
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS')} />
|
||||
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{serverType === 'selfhosted' && (
|
||||
<TextInput
|
||||
label={t('setup.server.url.label', 'Server URL')}
|
||||
placeholder="https://your-server.com"
|
||||
value={customUrl}
|
||||
onChange={(e) => {
|
||||
setCustomUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={testing}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && !customUrl && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSubmit} loading={testing} fullWidth>
|
||||
{testing ? t('setup.server.testing', 'Testing...') : t('common.continue', 'Continue')}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoginFormProps {
|
||||
serverUrl: string;
|
||||
isSaaS?: boolean;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@ -36,7 +37,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loadin
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
|
||||
@ -5,7 +5,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
|
||||
interface ModeSelectionProps {
|
||||
onSelect: (mode: 'offline' | 'server') => void;
|
||||
onSelect: (mode: 'saas' | 'selfhosted') => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
@ -17,31 +17,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('offline')}
|
||||
disabled={loading}
|
||||
leftSection={<ComputerIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.offline.title', 'Use Offline')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.offline.description', 'Run locally without an internet connection')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('server')}
|
||||
onClick={() => onSelect('saas')}
|
||||
disabled={loading}
|
||||
leftSection={<CloudIcon />}
|
||||
styles={{
|
||||
@ -52,12 +28,42 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text>
|
||||
<Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
|
||||
{t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('selfhosted')}
|
||||
disabled={loading}
|
||||
leftSection={<ComputerIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@ -41,7 +41,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
// Connection successful
|
||||
onSelect({
|
||||
url,
|
||||
server_type: 'selfhosted',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
|
||||
@ -7,13 +7,15 @@ import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
||||
import '@app/components/SetupWizard/SetupWizard.css';
|
||||
|
||||
enum SetupStep {
|
||||
ModeSelection,
|
||||
SaaSLogin,
|
||||
ServerSelection,
|
||||
Login,
|
||||
SelfHostedLogin,
|
||||
}
|
||||
|
||||
interface SetupWizardProps {
|
||||
@ -22,34 +24,42 @@ interface SetupWizardProps {
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const logoPath = useLogoPath();
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
|
||||
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleModeSelection = (mode: 'offline' | 'server') => {
|
||||
setSelectedMode(mode);
|
||||
const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
|
||||
setError(null);
|
||||
|
||||
if (mode === 'offline') {
|
||||
handleOfflineSetup();
|
||||
if (mode === 'saas') {
|
||||
// For SaaS, go directly to login screen with SaaS URL
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
} else {
|
||||
// For self-hosted, show server selection first
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOfflineSetup = async () => {
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No SaaS server configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await connectionModeService.switchToOffline();
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||
await tauriBackendService.startBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to set up offline mode:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to set up offline mode');
|
||||
console.error('SaaS login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'SaaS login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@ -57,10 +67,10 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.Login);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
};
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
const handleSelfHostedLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No server configured');
|
||||
return;
|
||||
@ -71,23 +81,25 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
setError(null);
|
||||
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
await connectionModeService.switchToServer(serverConfig);
|
||||
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
console.error('Self-hosted login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Self-hosted login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.Login) {
|
||||
if (activeStep === SetupStep.SaaSLogin) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setServerConfig(null);
|
||||
} else if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setSelectedMode(null);
|
||||
setServerConfig(null);
|
||||
}
|
||||
};
|
||||
@ -96,10 +108,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.welcome', 'Welcome to Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.title', 'Sign in to Stirling Cloud');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.title', 'Connect to Server');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.title', 'Sign In');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.title', 'Sign in to Server');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@ -109,10 +123,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.subtitle', 'Enter your credentials to continue');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@ -126,9 +142,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
{/* Logo Header */}
|
||||
<Stack gap="xs" align="center">
|
||||
<Image
|
||||
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
|
||||
src={logoPath}
|
||||
alt="Stirling PDF"
|
||||
h={32}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
|
||||
@ -153,14 +169,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
<ModeSelection onSelect={handleModeSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SaaSLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
isSaaS={true}
|
||||
onLogin={handleSaaSLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.Login && (
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
onLogin={handleLogin}
|
||||
isSaaS={false}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -2,4 +2,11 @@
|
||||
* Connection-related constants for desktop app
|
||||
*/
|
||||
|
||||
export const STIRLING_SAAS_URL = 'https://stirling.com/app';
|
||||
// SaaS server URL from environment variable (required)
|
||||
// The SaaS authentication server
|
||||
// Will throw error if VITE_SAAS_SERVER_URL is not set
|
||||
if (!import.meta.env.VITE_SAAS_SERVER_URL) {
|
||||
throw new Error('VITE_SAAS_SERVER_URL environment variable is required');
|
||||
}
|
||||
|
||||
export const STIRLING_SAAS_URL = import.meta.env.VITE_SAAS_SERVER_URL;
|
||||
|
||||
@ -25,9 +25,7 @@ export function useBackendInitializer(enabled = true) {
|
||||
|
||||
const initializeBackend = async () => {
|
||||
try {
|
||||
console.log('[BackendInitializer] Starting backend...');
|
||||
await tauriBackendService.startBackend(backendUrl);
|
||||
console.log('[BackendInitializer] Backend started successfully');
|
||||
|
||||
// Begin health checks after a short delay
|
||||
setTimeout(() => {
|
||||
|
||||
@ -267,8 +267,8 @@ const DEFAULT_BACKEND_URL =
|
||||
|
||||
/**
|
||||
* Desktop override exposing the backend URL based on connection mode.
|
||||
* - Offline mode: Uses local bundled backend (from env vars)
|
||||
* - Server mode: Uses configured server URL from connection config
|
||||
* - SaaS mode: Uses local bundled backend (from env vars)
|
||||
* - Self-hosted mode: Uses configured server URL from connection config
|
||||
*/
|
||||
export function useEndpointConfig(): EndpointConfig {
|
||||
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
|
||||
@ -276,10 +276,10 @@ export function useEndpointConfig(): EndpointConfig {
|
||||
useEffect(() => {
|
||||
connectionModeService.getCurrentConfig()
|
||||
.then((config) => {
|
||||
if (config.mode === 'server' && config.server_config?.url) {
|
||||
if (config.mode === 'selfhosted' && config.server_config?.url) {
|
||||
setBackendUrl(config.server_config.url);
|
||||
} else {
|
||||
// Offline mode - use default from env vars
|
||||
// SaaS mode - use default from env vars (local backend)
|
||||
setBackendUrl(DEFAULT_BACKEND_URL);
|
||||
}
|
||||
})
|
||||
|
||||
@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||
|
||||
// Add auth token for remote requests
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
if (isRemote) {
|
||||
const token = await authService.getAuthToken();
|
||||
if (token) {
|
||||
@ -59,9 +59,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
// Backend readiness check (for local backend)
|
||||
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
|
||||
const isOffline = await operationRouter.isOfflineMode();
|
||||
const isSaaS = await operationRouter.isSaaSMode();
|
||||
|
||||
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
|
||||
if (isSaaS && !skipCheck && !tauriBackendService.isBackendHealthy()) {
|
||||
const method = (extendedConfig.method || 'get').toLowerCase();
|
||||
if (method !== 'get') {
|
||||
const now = Date.now();
|
||||
@ -93,7 +93,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
if (isRemote) {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (serverConfig) {
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export type ConnectionMode = 'offline' | 'server';
|
||||
export type ServerType = 'saas' | 'selfhosted';
|
||||
export type ConnectionMode = 'saas' | 'selfhosted';
|
||||
|
||||
export interface ServerConfig {
|
||||
url: string;
|
||||
server_type: ServerType;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
@ -31,7 +29,7 @@ export class ConnectionModeService {
|
||||
if (!this.configLoadedOnce) {
|
||||
await this.loadConfig();
|
||||
}
|
||||
return this.currentConfig || { mode: 'offline', server_config: null };
|
||||
return this.currentConfig || { mode: 'saas', server_config: null };
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<ConnectionMode> {
|
||||
@ -64,38 +62,40 @@ export class ConnectionModeService {
|
||||
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 };
|
||||
// Default to SaaS mode on error
|
||||
this.currentConfig = { mode: 'saas', server_config: null };
|
||||
this.configLoadedOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
async switchToOffline(): Promise<void> {
|
||||
console.log('Switching to offline mode');
|
||||
async switchToSaaS(saasServerUrl: string): Promise<void> {
|
||||
console.log('Switching to SaaS mode');
|
||||
|
||||
const serverConfig: ServerConfig = { url: saasServerUrl };
|
||||
|
||||
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<void> {
|
||||
console.log('Switching to server mode:', serverConfig);
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'server',
|
||||
mode: 'saas',
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'server', server_config: serverConfig };
|
||||
this.currentConfig = { mode: 'saas', server_config: serverConfig };
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to server mode successfully');
|
||||
console.log('Switched to SaaS mode successfully');
|
||||
}
|
||||
|
||||
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
|
||||
console.log('Switching to self-hosted mode:', serverConfig);
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'selfhosted',
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig };
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to self-hosted mode successfully');
|
||||
}
|
||||
|
||||
async testConnection(url: string): Promise<boolean> {
|
||||
@ -126,6 +126,16 @@ export class ConnectionModeService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async resetSetupCompletion(): Promise<void> {
|
||||
try {
|
||||
await invoke('reset_setup_completion');
|
||||
console.log('Setup completion flag reset successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to reset setup completion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionModeService = ConnectionModeService.getInstance();
|
||||
|
||||
@ -22,14 +22,16 @@ export class OperationRouter {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Current implementation: simple mode-based routing
|
||||
if (mode === 'offline') {
|
||||
if (mode === 'saas') {
|
||||
// SaaS mode: For now, all operations run locally
|
||||
// Future enhancement: complex operations will be sent to SaaS server
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// In server mode, currently all operations go to remote
|
||||
// In self-hosted 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)) {
|
||||
// if (mode === 'selfhosted' && operation && this.isSimpleOperation(operation)) {
|
||||
// return 'local';
|
||||
// }
|
||||
|
||||
@ -66,19 +68,19 @@ export class OperationRouter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're currently in remote mode
|
||||
* Checks if we're currently in self-hosted mode
|
||||
*/
|
||||
async isRemoteMode(): Promise<boolean> {
|
||||
async isSelfHostedMode(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
return mode === 'server';
|
||||
return mode === 'selfhosted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're currently in offline mode
|
||||
* Checks if we're currently in SaaS mode
|
||||
*/
|
||||
async isOfflineMode(): Promise<boolean> {
|
||||
async isSaaSMode(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
return mode === 'offline';
|
||||
return mode === 'saas';
|
||||
}
|
||||
|
||||
// Future enhancement: operation classification
|
||||
|
||||
@ -64,7 +64,6 @@ export class TauriBackendService {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TauriBackendService] Initializing external backend monitoring');
|
||||
this.backendStarted = true; // Mark as active for health checks
|
||||
this.setStatus('starting');
|
||||
this.beginHealthMonitoring();
|
||||
@ -82,19 +81,17 @@ export class TauriBackendService {
|
||||
this.setStatus('starting');
|
||||
|
||||
this.startPromise = invoke('start_backend', { backendUrl })
|
||||
.then(async (result) => {
|
||||
console.log('Backend started:', result);
|
||||
.then(async () => {
|
||||
this.backendStarted = true;
|
||||
this.setStatus('starting');
|
||||
|
||||
// Poll for the dynamically assigned port
|
||||
await this.waitForPort();
|
||||
|
||||
this.beginHealthMonitoring();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setStatus('unhealthy');
|
||||
console.error('Failed to start backend:', error);
|
||||
console.error('[TauriBackendService] Failed to start backend:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
@ -105,13 +102,11 @@ export class TauriBackendService {
|
||||
}
|
||||
|
||||
private async waitForPort(maxAttempts = 30): Promise<void> {
|
||||
console.log('[TauriBackendService] Waiting for backend port assignment...');
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const port = await invoke<number | null>('get_backend_port');
|
||||
if (port) {
|
||||
this.backendPort = port;
|
||||
console.log(`[TauriBackendService] Backend port detected: ${port}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@ -138,11 +133,11 @@ export class TauriBackendService {
|
||||
async checkBackendHealth(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// For remote server mode, check the configured server
|
||||
if (mode !== 'offline') {
|
||||
// For self-hosted mode, check the configured remote server
|
||||
if (mode === 'selfhosted') {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
console.error('[TauriBackendService] Server mode but no server URL configured');
|
||||
console.error('[TauriBackendService] Self-hosted mode but no server URL configured');
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
@ -161,21 +156,20 @@ export class TauriBackendService {
|
||||
} catch (error) {
|
||||
const errorStr = String(error);
|
||||
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
|
||||
console.error('[TauriBackendService] Server health check failed:', error);
|
||||
console.error('[TauriBackendService] Self-hosted server health check failed:', error);
|
||||
}
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For offline mode, check the bundled backend via Rust
|
||||
// For SaaS mode, check the bundled local backend via Rust
|
||||
if (!this.backendStarted) {
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.backendPort) {
|
||||
console.debug('[TauriBackendService] Backend port not available yet');
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -197,7 +191,6 @@ export class TauriBackendService {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const isHealthy = await this.checkBackendHealth();
|
||||
if (isHealthy) {
|
||||
console.log('Backend is healthy');
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
@ -210,7 +203,6 @@ export class TauriBackendService {
|
||||
* Reset backend state (used when switching from external to local backend)
|
||||
*/
|
||||
reset(): void {
|
||||
console.log('[TauriBackendService] Resetting backend state');
|
||||
this.backendStarted = false;
|
||||
this.backendPort = null;
|
||||
this.setStatus('stopped');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user