Merge branch 'feature/onboardingSlides' of github.com:Stirling-Tools/Stirling-PDF into feature/onboardingSlides

This commit is contained in:
EthanHealy01
2025-11-23 23:52:33 +00:00
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

@@ -5661,15 +5661,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",
@@ -5711,16 +5719,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');