diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 09ef22a16..856d1fd3b 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -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: diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 50e047698..1942cdb2a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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", diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index 9ce4c3cda..64437a1e2 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct SupabaseUser { + email: Option, + user_metadata: Option, +} + +#[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 { 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, - }) } diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index 27a4a7dfd..af2b149bc 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -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()); } } diff --git a/frontend/src-tauri/src/commands/connection.rs b/frontend/src-tauri/src/commands/connection.rs index 097d411b5..f2accec1f 100644 --- a/frontend/src-tauri/src/commands/connection.rs +++ b/frontend/src-tauri/src/commands/connection.rs @@ -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 = store .get(SERVER_CONFIG_KEY) @@ -109,3 +109,22 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result { 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(()) +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index ecf2f0a8d..0f58f2a1d 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -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::{ diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index cc8e5b65f..f3b8259dc 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -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, diff --git a/frontend/src-tauri/src/state/connection_state.rs b/frontend/src-tauri/src/state/connection_state.rs index e6a924956..61db28cbe 100644 --- a/frontend/src-tauri/src/state/connection_state.rs +++ b/frontend/src-tauri/src/state/connection_state.rs @@ -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, } } diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index f6fc1ea1b..84d455c51 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -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 }) { }} > { - // 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(); }} /> diff --git a/frontend/src/desktop/components/ConnectionSettings.tsx b/frontend/src/desktop/components/ConnectionSettings.tsx index da7e2cb61..b0ba688c9 100644 --- a/frontend/src/desktop/components/ConnectionSettings.tsx +++ b/frontend/src/desktop/components/ConnectionSettings.tsx @@ -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(null); const [userInfo, setUserInfo] = useState(null); const [loading, setLoading] = useState(false); - const [showServerModal, setShowServerModal] = useState(false); - const [showLoginModal, setShowLoginModal] = useState(false); - const [newServerConfig, setNewServerConfig] = useState(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 = () => { {t('settings.connection.title', 'Connection Mode')} - - {config.mode === 'offline' - ? t('settings.connection.mode.offline', 'Offline') - : t('settings.connection.mode.server', 'Server')} + + {config.mode === 'saas' + ? t('settings.connection.mode.saas', 'Stirling Cloud') + : t('settings.connection.mode.selfhosted', 'Self-Hosted')} - {config.mode === 'server' && config.server_config && ( + {(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && ( <>
{t('settings.connection.server', 'Server')} - {config.server_config.url} + {config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
@@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => { )} - {config.mode === 'offline' ? ( - - ) : ( - <> - - - - )} +
- - {/* Server selection modal */} - setShowServerModal(false)} - title={t('settings.connection.selectServer', 'Select Server')} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > - - - - {/* Login modal */} - { - setShowLoginModal(false); - setNewServerConfig(null); - }} - title={t('settings.connection.login', 'Login')} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > - {newServerConfig && ( - - )} - ); }; - -// 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(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 ( - - setServerType(value as 'saas' | 'selfhosted')}> - - - - - - - {serverType === 'selfhosted' && ( - { - setCustomUrl(e.target.value); - setError(null); - }} - disabled={testing} - error={error} - /> - )} - - {error && !customUrl && ( - - {error} - - )} - - - - ); -}; diff --git a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx index be9951995..5facdf96b 100644 --- a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx +++ b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx @@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next'; interface LoginFormProps { serverUrl: string; + isSaaS?: boolean; onLogin: (username: string, password: string) => Promise; loading: boolean; } -export const LoginForm: React.FC = ({ serverUrl, onLogin, loading }) => { +export const LoginForm: React.FC = ({ 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 = ({ serverUrl, onLogin, loadin
- {t('setup.login.connectingTo', 'Connecting to:')} {serverUrl} + {t('setup.login.connectingTo', 'Connecting to:')} {isSaaS ? 'stirling.com' : serverUrl} void; + onSelect: (mode: 'saas' | 'selfhosted') => void; loading: boolean; } @@ -17,31 +17,7 @@ export const ModeSelection: React.FC = ({ onSelect, loading - - + + diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx index 7d0f8de1e..3ca5ea65b 100644 --- a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -41,7 +41,6 @@ export const ServerSelection: React.FC = ({ onSelect, load // Connection successful onSelect({ url, - server_type: 'selfhosted', }); } catch (error) { console.error('Connection test failed:', error); diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index 3b47245e0..6e4b7b6e8 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -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 = ({ onComplete }) => { const { t } = useTranslation(); + const logoPath = useLogoPath(); const [activeStep, setActiveStep] = useState(SetupStep.ModeSelection); - const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null); const [serverConfig, setServerConfig] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ onComplete }) => { {/* Logo Header */} Stirling PDF @@ -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} /> )} diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts index b9ba31f77..7f7d53836 100644 --- a/frontend/src/desktop/constants/connection.ts +++ b/frontend/src/desktop/constants/connection.ts @@ -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; diff --git a/frontend/src/desktop/hooks/useBackendInitializer.ts b/frontend/src/desktop/hooks/useBackendInitializer.ts index e14c7ea53..8be4e3c89 100644 --- a/frontend/src/desktop/hooks/useBackendInitializer.ts +++ b/frontend/src/desktop/hooks/useBackendInitializer.ts @@ -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(() => { diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index f0156b6dc..529d3f0f5 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -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); } }) diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index 2ba6c5497..d01c0d997 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -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) { diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index f1a96c9c5..f01dbc40a 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -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(); diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts index 723878b97..871595956 100644 --- a/frontend/src/desktop/services/operationRouter.ts +++ b/frontend/src/desktop/services/operationRouter.ts @@ -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 diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index ce8bd9336..9840d93da 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -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');