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
21 changed files with 382 additions and 397 deletions
@@ -17,23 +17,23 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null);
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
// Load connection mode on mount
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []);
// Initialize backend health monitoring for server mode
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
console.log('[AppProviders] Initializing external backend monitoring for server mode');
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
void tauriBackendService.initializeExternalBackend();
}
}, [setupComplete, isFirstLaunch, connectionMode]);
// Only start bundled backend if in offline mode and setup is complete
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
// Only start bundled backend if in SaaS mode (local backend) and setup is complete
// Self-hosted mode connects to remote server so doesn't need local backend
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
useBackendInitializer(shouldStartBackend);
// Show setup wizard on first launch
@@ -51,8 +51,23 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<SetupWizard
onComplete={() => {
// Reload the page to reinitialize with new connection config
onComplete={async () => {
// Wait for backend to become healthy before reloading
// This prevents reloading mid-startup which would interrupt the backend
const maxWaitTime = 60000; // 60 seconds max
const checkInterval = 1000; // Check every second
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
if (tauriBackendService.isBackendHealthy()) {
window.location.reload();
return;
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
// If we timeout, reload anyway
console.warn('[AppProviders] Backend health check timeout, reloading anyway...');
window.location.reload();
}}
/>
@@ -1,24 +1,15 @@
import React, { useState, useEffect } from 'react';
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import {
connectionModeService,
ConnectionConfig,
ServerConfig,
} from '@app/services/connectionModeService';
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
const [showServerModal, setShowServerModal] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [newServerConfig, setNewServerConfig] = useState<ServerConfig | null>(null);
// Load current config on mount
useEffect(() => {
@@ -26,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
const currentConfig = await connectionModeService.getCurrentConfig();
setConfig(currentConfig);
if (currentConfig.mode === 'server') {
if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
const user = await authService.getUserInfo();
setUserInfo(user);
}
@@ -35,80 +26,26 @@ export const ConnectionSettings: React.FC = () => {
loadConfig();
}, []);
const handleSwitchToOffline = async () => {
try {
setLoading(true);
await connectionModeService.switchToOffline();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
// Reload the page to start the local backend
window.location.reload();
} catch (error) {
console.error('Failed to switch to offline:', error);
} finally {
setLoading(false);
}
};
const handleSwitchToServer = () => {
setShowServerModal(true);
};
const handleServerConfigSubmit = (serverConfig: ServerConfig) => {
setNewServerConfig(serverConfig);
setShowServerModal(false);
setShowLoginModal(true);
};
const handleLogin = async (username: string, password: string) => {
if (!newServerConfig) return;
try {
setLoading(true);
// Login
await authService.login(newServerConfig.url, username, password);
// Switch to server mode
await connectionModeService.switchToServer(newServerConfig);
// Reload config and user info
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
const user = await authService.getUserInfo();
setUserInfo(user);
setShowLoginModal(false);
setNewServerConfig(null);
// Reload the page to stop local backend and initialize external backend monitoring
window.location.reload();
} catch (error) {
console.error('Login failed:', error);
throw error; // Let LoginForm handle the error
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
setLoading(true);
await authService.logout();
// Switch to offline mode
await connectionModeService.switchToOffline();
// Switch to SaaS mode
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
// Reset setup completion to force login screen on reload
await connectionModeService.resetSetupCompletion();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
// Reload the page to clear all state and reconnect to local backend
// Clear URL to home page before reload so we don't return to settings after re-login
window.history.replaceState({}, '', '/');
// Reload the page to clear all state and show login screen
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
@@ -127,21 +64,21 @@ export const ConnectionSettings: React.FC = () => {
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light">
{config.mode === 'offline'
? t('settings.connection.mode.offline', 'Offline')
: t('settings.connection.mode.server', 'Server')}
<Badge color={config.mode === 'saas' ? 'blue' : 'green'} variant="light">
{config.mode === 'saas'
? t('settings.connection.mode.saas', 'Stirling Cloud')
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
</Badge>
</Group>
{config.mode === 'server' && config.server_config && (
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
<>
<div>
<Text size="sm" fw={500}>
{t('settings.connection.server', 'Server')}
</Text>
<Text size="sm" c="dimmed">
{config.server_config.url}
{config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
</Text>
</div>
@@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => {
)}
<Group mt="md">
{config.mode === 'offline' ? (
<Button onClick={handleSwitchToServer} disabled={loading}>
{t('settings.connection.switchToServer', 'Connect to Server')}
</Button>
) : (
<>
<Button onClick={handleSwitchToOffline} variant="default" disabled={loading}>
{t('settings.connection.switchToOffline', 'Switch to Offline')}
</Button>
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Logout')}
</Button>
</>
)}
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Log Out')}
</Button>
</Group>
</Stack>
</Card>
{/* Server selection modal */}
<Modal
opened={showServerModal}
onClose={() => setShowServerModal(false)}
title={t('settings.connection.selectServer', 'Select Server')}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
</Modal>
{/* Login modal */}
<Modal
opened={showLoginModal}
onClose={() => {
setShowLoginModal(false);
setNewServerConfig(null);
}}
title={t('settings.connection.login', 'Login')}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{newServerConfig && (
<LoginForm
serverUrl={newServerConfig.url}
onLogin={handleLogin}
loading={loading}
/>
)}
</Modal>
</>
);
};
// Mini server selection component for settings
const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({
onSubmit,
}) => {
const { t } = useTranslation();
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
const [customUrl, setCustomUrl] = useState('');
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
if (!url) {
setError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
return;
}
setTesting(true);
setError(null);
try {
const isReachable = await connectionModeService.testConnection(url);
if (!isReachable) {
setError(t('setup.server.error.unreachable', 'Could not connect to server'));
setTesting(false);
return;
}
onSubmit({
url,
server_type: serverType,
});
} catch (err) {
setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed'));
setTesting(false);
}
};
return (
<Stack gap="md">
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
<Stack gap="xs">
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS')} />
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
</Stack>
</Radio.Group>
{serverType === 'selfhosted' && (
<TextInput
label={t('setup.server.url.label', 'Server URL')}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
setCustomUrl(e.target.value);
setError(null);
}}
disabled={testing}
error={error}
/>
)}
{error && !customUrl && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Button onClick={handleSubmit} loading={testing} fullWidth>
{testing ? t('setup.server.testing', 'Testing...') : t('common.continue', 'Continue')}
</Button>
</Stack>
);
};
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
interface LoginFormProps {
serverUrl: string;
isSaaS?: boolean;
onLogin: (username: string, password: string) => Promise<void>;
loading: boolean;
}
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -36,7 +37,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loadin
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
</Text>
<TextInput
@@ -5,7 +5,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
import ComputerIcon from '@mui/icons-material/Computer';
interface ModeSelectionProps {
onSelect: (mode: 'offline' | 'server') => void;
onSelect: (mode: 'saas' | 'selfhosted') => void;
loading: boolean;
}
@@ -17,31 +17,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
<Button
size="xl"
variant="default"
onClick={() => onSelect('offline')}
disabled={loading}
leftSection={<ComputerIcon />}
styles={{
root: {
height: 'auto',
padding: '1.25rem',
},
inner: {
justifyContent: 'flex-start',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.offline.title', 'Use Offline')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.offline.description', 'Run locally without an internet connection')}
</Text>
</div>
</Button>
<Button
size="xl"
variant="default"
onClick={() => onSelect('server')}
onClick={() => onSelect('saas')}
disabled={loading}
leftSection={<CloudIcon />}
styles={{
@@ -52,12 +28,42 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
inner: {
justifyContent: 'flex-start',
},
section: {
marginRight: '1rem',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text>
<Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
{t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
</Text>
</div>
</Button>
<Button
size="xl"
variant="default"
onClick={() => onSelect('selfhosted')}
disabled={loading}
leftSection={<ComputerIcon />}
styles={{
root: {
height: 'auto',
padding: '1.25rem',
},
inner: {
justifyContent: 'flex-start',
},
section: {
marginRight: '1rem',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
</Text>
</div>
</Button>
@@ -41,7 +41,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Connection successful
onSelect({
url,
server_type: 'selfhosted',
});
} catch (error) {
console.error('Connection test failed:', error);
@@ -7,13 +7,15 @@ import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import '@app/components/SetupWizard/SetupWizard.css';
enum SetupStep {
ModeSelection,
SaaSLogin,
ServerSelection,
Login,
SelfHostedLogin,
}
interface SetupWizardProps {
@@ -22,34 +24,42 @@ interface SetupWizardProps {
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const { t } = useTranslation();
const logoPath = useLogoPath();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleModeSelection = (mode: 'offline' | 'server') => {
setSelectedMode(mode);
const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
setError(null);
if (mode === 'offline') {
handleOfflineSetup();
if (mode === 'saas') {
// For SaaS, go directly to login screen with SaaS URL
setServerConfig({ url: STIRLING_SAAS_URL });
setActiveStep(SetupStep.SaaSLogin);
} else {
// For self-hosted, show server selection first
setActiveStep(SetupStep.ServerSelection);
}
};
const handleOfflineSetup = async () => {
const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No SaaS server configured');
return;
}
try {
setLoading(true);
setError(null);
await connectionModeService.switchToOffline();
await authService.login(serverConfig.url, username, password);
await connectionModeService.switchToSaaS(serverConfig.url);
await tauriBackendService.startBackend();
onComplete();
} catch (err) {
console.error('Failed to set up offline mode:', err);
setError(err instanceof Error ? err.message : 'Failed to set up offline mode');
console.error('SaaS login failed:', err);
setError(err instanceof Error ? err.message : 'SaaS login failed');
setLoading(false);
}
};
@@ -57,10 +67,10 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
setActiveStep(SetupStep.Login);
setActiveStep(SetupStep.SelfHostedLogin);
};
const handleLogin = async (username: string, password: string) => {
const handleSelfHostedLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No server configured');
return;
@@ -71,23 +81,25 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setError(null);
await authService.login(serverConfig.url, username, password);
await connectionModeService.switchToServer(serverConfig);
await connectionModeService.switchToSelfHosted(serverConfig);
await tauriBackendService.initializeExternalBackend();
onComplete();
} catch (err) {
console.error('Login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
console.error('Self-hosted login failed:', err);
setError(err instanceof Error ? err.message : 'Self-hosted login failed');
setLoading(false);
}
};
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.Login) {
if (activeStep === SetupStep.SaaSLogin) {
setActiveStep(SetupStep.ModeSelection);
setServerConfig(null);
} else if (activeStep === SetupStep.SelfHostedLogin) {
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.ModeSelection);
setSelectedMode(null);
setServerConfig(null);
}
};
@@ -96,10 +108,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
switch (activeStep) {
case SetupStep.ModeSelection:
return t('setup.welcome', 'Welcome to Stirling PDF');
case SetupStep.SaaSLogin:
return t('setup.saas.title', 'Sign in to Stirling Cloud');
case SetupStep.ServerSelection:
return t('setup.server.title', 'Connect to Server');
case SetupStep.Login:
return t('setup.login.title', 'Sign In');
case SetupStep.SelfHostedLogin:
return t('setup.selfhosted.title', 'Sign in to Server');
default:
return '';
}
@@ -109,10 +123,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
switch (activeStep) {
case SetupStep.ModeSelection:
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
case SetupStep.SaaSLogin:
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
case SetupStep.ServerSelection:
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
case SetupStep.Login:
return t('setup.login.subtitle', 'Enter your credentials to continue');
case SetupStep.SelfHostedLogin:
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
default:
return '';
}
@@ -126,9 +142,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
{/* Logo Header */}
<Stack gap="xs" align="center">
<Image
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
src={logoPath}
alt="Stirling PDF"
h={32}
h={64}
fit="contain"
/>
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
@@ -153,14 +169,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
<ModeSelection onSelect={handleModeSelection} loading={loading} />
)}
{activeStep === SetupStep.SaaSLogin && (
<LoginForm
serverUrl={serverConfig?.url || ''}
isSaaS={true}
onLogin={handleSaaSLogin}
loading={loading}
/>
)}
{activeStep === SetupStep.ServerSelection && (
<ServerSelection onSelect={handleServerSelection} loading={loading} />
)}
{activeStep === SetupStep.Login && (
{activeStep === SetupStep.SelfHostedLogin && (
<LoginForm
serverUrl={serverConfig?.url || ''}
onLogin={handleLogin}
isSaaS={false}
onLogin={handleSelfHostedLogin}
loading={loading}
/>
)}