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:
James Brunton 2025-11-22 00:38:59 +00:00 committed by GitHub
parent e1a879a5f6
commit e8e98128d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 382 additions and 397 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}}
/>

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');