From fb704cc90106ed09ea7621cda0dca0b977f50bb7 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Nov 2025 17:12:09 +0000 Subject: [PATCH] Add UI for backend selection --- .../desktop/components/ConnectionSettings.tsx | 275 ++++++++++++++++++ .../components/SetupWizard/LoginForm.tsx | 78 +++++ .../components/SetupWizard/ModeSelection.tsx | 47 +++ .../SetupWizard/ServerSelection.tsx | 98 +++++++ .../desktop/components/SetupWizard/index.tsx | 163 +++++++++++ frontend/src/desktop/constants/connection.ts | 5 + .../src/desktop/hooks/useAppInitialization.ts | 57 +++- .../desktop/hooks/useBackendInitializer.ts | 10 +- .../src/desktop/services/operationRouter.ts | 12 +- 9 files changed, 732 insertions(+), 13 deletions(-) create mode 100644 frontend/src/desktop/components/ConnectionSettings.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/LoginForm.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/ModeSelection.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/ServerSelection.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/index.tsx create mode 100644 frontend/src/desktop/constants/connection.ts diff --git a/frontend/src/desktop/components/ConnectionSettings.tsx b/frontend/src/desktop/components/ConnectionSettings.tsx new file mode 100644 index 000000000..a57fde8d8 --- /dev/null +++ b/frontend/src/desktop/components/ConnectionSettings.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react'; +import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { + connectionModeService, + ConnectionConfig, + ServerConfig, +} 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'; + +export const ConnectionSettings: React.FC = () => { + const { t } = useTranslation(); + const [config, setConfig] = useState(null); + const [userInfo, setUserInfo] = useState(null); + const [loading, setLoading] = useState(false); + const [showServerModal, setShowServerModal] = useState(false); + const [showLoginModal, setShowLoginModal] = useState(false); + const [newServerConfig, setNewServerConfig] = useState(null); + + // Load current config on mount + useEffect(() => { + const loadConfig = async () => { + const currentConfig = await connectionModeService.getCurrentConfig(); + setConfig(currentConfig); + + if (currentConfig.mode === 'server') { + const user = await authService.getUserInfo(); + setUserInfo(user); + } + }; + + loadConfig(); + }, []); + + const handleSwitchToOffline = async () => { + try { + setLoading(true); + await connectionModeService.switchToOffline(); + + // Reload config + const newConfig = await connectionModeService.getCurrentConfig(); + setConfig(newConfig); + setUserInfo(null); + } 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); + } 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(); + + // Reload config + const newConfig = await connectionModeService.getCurrentConfig(); + setConfig(newConfig); + setUserInfo(null); + } catch (error) { + console.error('Logout failed:', error); + } finally { + setLoading(false); + } + }; + + if (!config) { + return {t('common.loading', 'Loading...')}; + } + + return ( + <> + + + + {t('settings.connection.title', 'Connection Mode')} + + {config.mode === 'offline' + ? t('settings.connection.mode.offline', 'Offline') + : t('settings.connection.mode.server', 'Server')} + + + + {config.mode === 'server' && config.server_config && ( + <> +
+ + {t('settings.connection.server', 'Server')} + + + {config.server_config.url} + +
+ + {userInfo && ( +
+ + {t('settings.connection.user', 'Logged in as')} + + + {userInfo.username} + {userInfo.email && ` (${userInfo.email})`} + +
+ )} + + )} + + + {config.mode === 'offline' ? ( + + ) : ( + <> + + + + )} + +
+
+ + {/* Server selection modal */} + setShowServerModal(false)} + title={t('settings.connection.selectServer', 'Select Server')} + > + + + + {/* Login modal */} + { + setShowLoginModal(false); + setNewServerConfig(null); + }} + title={t('settings.connection.login', 'Login')} + > + {newServerConfig && ( + + )} + + + ); +}; + +// Mini server selection component for settings +const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({ + onSubmit, +}) => { + const { t } = useTranslation(); + const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas'); + const [customUrl, setCustomUrl] = useState(''); + const [testing, setTesting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim(); + + if (!url) { + setError(t('setup.server.error.emptyUrl', 'Please enter a server URL')); + return; + } + + setTesting(true); + setError(null); + + try { + const isReachable = await connectionModeService.testConnection(url); + + if (!isReachable) { + setError(t('setup.server.error.unreachable', 'Could not connect to server')); + setTesting(false); + return; + } + + onSubmit({ + url, + server_type: serverType, + }); + } catch (err) { + setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed')); + setTesting(false); + } + }; + + return ( + + setServerType(value as 'saas' | 'selfhosted')}> + + + + + + + {serverType === 'selfhosted' && ( + { + setCustomUrl(e.target.value); + setError(null); + }} + disabled={testing} + error={error} + /> + )} + + {error && !customUrl && ( + + {error} + + )} + + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx new file mode 100644 index 000000000..5d14638a6 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { Stack, TextInput, PasswordInput, Button, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +interface LoginFormProps { + serverUrl: string; + onLogin: (username: string, password: string) => Promise; + loading: boolean; +} + +export const LoginForm: React.FC = ({ serverUrl, onLogin, loading }) => { + const { t } = useTranslation(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [validationError, setValidationError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validation + if (!username.trim()) { + setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username')); + return; + } + + if (!password) { + setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password')); + return; + } + + setValidationError(null); + await onLogin(username.trim(), password); + }; + + return ( +
+ + + {t('setup.login.connectingTo', 'Connecting to:')} {serverUrl} + + + { + setUsername(e.target.value); + setValidationError(null); + }} + disabled={loading} + required + /> + + { + setPassword(e.target.value); + setValidationError(null); + }} + disabled={loading} + required + /> + + {validationError && ( + + {validationError} + + )} + + + +
+ ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx b/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx new file mode 100644 index 000000000..854f3e92f --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Stack, Button, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { IconCloud, IconDeviceDesktop } from '@tabler/icons-react'; + +interface ModeSelectionProps { + onSelect: (mode: 'offline' | 'server') => void; + loading: boolean; +} + +export const ModeSelection: React.FC = ({ onSelect, loading }) => { + const { t } = useTranslation(); + + return ( + + + + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx new file mode 100644 index 000000000..16cef1d15 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { Stack, Button, TextInput, Radio, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ServerConfig } from '@app/services/connectionModeService'; +import { connectionModeService } from '@app/services/connectionModeService'; +import { STIRLING_SAAS_URL } from '@app/constants/connection'; + +interface ServerSelectionProps { + onSelect: (config: ServerConfig) => void; + loading: boolean; +} + +export const ServerSelection: React.FC = ({ onSelect, loading }) => { + const { t } = useTranslation(); + const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas'); + const [customUrl, setCustomUrl] = useState(''); + const [testing, setTesting] = useState(false); + const [testError, setTestError] = useState(null); + + const handleContinue = async () => { + const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim(); + + if (!url) { + setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL')); + return; + } + + // Test connection before proceeding + setTesting(true); + setTestError(null); + + try { + const isReachable = await connectionModeService.testConnection(url); + + if (!isReachable) { + setTestError(t('setup.server.error.unreachable', 'Could not connect to server')); + setTesting(false); + return; + } + + // Connection successful + onSelect({ + url, + server_type: serverType, + }); + } catch (error) { + console.error('Connection test failed:', error); + setTestError( + error instanceof Error + ? error.message + : t('setup.server.error.testFailed', 'Connection test failed') + ); + } finally { + setTesting(false); + } + }; + + return ( + + setServerType(value as 'saas' | 'selfhosted')}> + + + + + + + {serverType === 'selfhosted' && ( + { + setCustomUrl(e.target.value); + setTestError(null); + }} + disabled={loading || testing} + error={testError} + description={t( + 'setup.server.url.description', + 'Enter the full URL of your self-hosted Stirling PDF server' + )} + /> + )} + + {testError && ( + + {testError} + + )} + + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx new file mode 100644 index 000000000..c0fe2f019 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -0,0 +1,163 @@ +import React, { useState } from 'react'; +import { Container, Paper, Stepper, Button, Group, Title, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ModeSelection } from '@app/components/SetupWizard/ModeSelection'; +import { ServerSelection } from '@app/components/SetupWizard/ServerSelection'; +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'; + +enum SetupStep { + ModeSelection, + ServerSelection, + Login, +} + +interface SetupWizardProps { + onComplete: () => void; +} + +export const SetupWizard: React.FC = ({ onComplete }) => { + const { t } = useTranslation(); + const [activeStep, setActiveStep] = useState(SetupStep.ModeSelection); + const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null); + const [serverConfig, setServerConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleModeSelection = (mode: 'offline' | 'server') => { + setSelectedMode(mode); + setError(null); + + if (mode === 'offline') { + // Skip directly to completion for offline mode + handleOfflineSetup(); + } else { + // Go to server selection + setActiveStep(SetupStep.ServerSelection); + } + }; + + const handleOfflineSetup = async () => { + try { + setLoading(true); + setError(null); + + // Set connection mode to offline + await connectionModeService.switchToOffline(); + + // Start the local backend + await tauriBackendService.startBackend(); + + // Complete setup + onComplete(); + } catch (err) { + console.error('Failed to set up offline mode:', err); + setError(err instanceof Error ? err.message : 'Failed to set up offline mode'); + setLoading(false); + } + }; + + const handleServerSelection = (config: ServerConfig) => { + setServerConfig(config); + setError(null); + setActiveStep(SetupStep.Login); + }; + + const handleLogin = async (username: string, password: string) => { + if (!serverConfig) { + setError('No server configured'); + return; + } + + try { + setLoading(true); + setError(null); + + // Attempt login + await authService.login(serverConfig.url, username, password); + + // Set connection mode to server + await connectionModeService.switchToServer(serverConfig); + + // Start local backend (for hybrid execution support) + await tauriBackendService.startBackend(); + + // Complete setup + onComplete(); + } catch (err) { + console.error('Login failed:', err); + setError(err instanceof Error ? err.message : 'Login failed'); + setLoading(false); + } + }; + + const handleBack = () => { + setError(null); + if (activeStep === SetupStep.Login) { + // From login back to server selection + setActiveStep(SetupStep.ServerSelection); + } else if (activeStep === SetupStep.ServerSelection) { + // From server selection back to mode selection + setActiveStep(SetupStep.ModeSelection); + setSelectedMode(null); + setServerConfig(null); + } + }; + + return ( + + + + {t('setup.welcome', 'Welcome to Stirling PDF')} + + + + {t('setup.description', 'Get started by choosing how you want to use Stirling PDF')} + + + + + + + + + + + + + + + + + {error && ( + + {error} + + )} + + {activeStep > SetupStep.ModeSelection && ( + + + + )} + + + ); +}; diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts new file mode 100644 index 000000000..b9ba31f77 --- /dev/null +++ b/frontend/src/desktop/constants/connection.ts @@ -0,0 +1,5 @@ +/** + * Connection-related constants for desktop app + */ + +export const STIRLING_SAAS_URL = 'https://stirling.com/app'; diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index a34547bcd..42321a1a4 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -1,18 +1,55 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useBackendInitializer } from '@app/hooks/useBackendInitializer'; import { useOpenedFile } from '@app/hooks/useOpenedFile'; import { fileOpenService } from '@app/services/fileOpenService'; import { useFileManagement } from '@app/contexts/file/fileHooks'; +import { connectionModeService } from '@app/services/connectionModeService'; +import { authService } from '@app/services/authService'; /** * App initialization hook * Desktop version: Handles Tauri-specific initialization - * - Starts the backend on app startup + * - Checks for first launch and shows setup wizard if needed + * - Starts the backend on app startup (after setup) + * - Initializes auth state for server mode * - Handles files opened with the app (adds directly to FileContext) */ -export function useAppInitialization(): void { - // Initialize backend on app startup - useBackendInitializer(); +export function useAppInitialization(): { isFirstLaunch: boolean; setupComplete: boolean } { + const [isFirstLaunch, setIsFirstLaunch] = useState(false); + const [setupComplete, setSetupComplete] = useState(false); + const setupCheckCompleteRef = useRef(false); + + // Check if this is first launch + useEffect(() => { + const checkFirstLaunch = async () => { + try { + const firstLaunch = await connectionModeService.isFirstLaunch(); + setIsFirstLaunch(firstLaunch); + + if (!firstLaunch) { + // Not first launch - initialize normally + await authService.initializeAuthState(); + setSetupComplete(true); + } + + setupCheckCompleteRef.current = true; + } catch (error) { + console.error('Failed to check first launch:', error); + // On error, assume not first launch and proceed + setIsFirstLaunch(false); + setSetupComplete(true); + setupCheckCompleteRef.current = true; + } + }; + + if (!setupCheckCompleteRef.current) { + checkFirstLaunch(); + } + }, []); + + // Initialize backend on app startup (only if setup is complete) + const shouldStartBackend = setupComplete && !isFirstLaunch; + useBackendInitializer(shouldStartBackend); // Get file management actions const { addFiles } = useFileManagement(); @@ -91,4 +128,14 @@ export function useAppInitialization(): void { // Clean up listener on unmount return unlisten; }, [addFiles]); + + return { isFirstLaunch, setupComplete }; +} + +export function useSetupCompletion(): (completed: boolean) => void { + const [, setSetupComplete] = useState(false); + + return (completed: boolean) => { + setSetupComplete(completed); + }; } diff --git a/frontend/src/desktop/hooks/useBackendInitializer.ts b/frontend/src/desktop/hooks/useBackendInitializer.ts index 259f4d875..e14c7ea53 100644 --- a/frontend/src/desktop/hooks/useBackendInitializer.ts +++ b/frontend/src/desktop/hooks/useBackendInitializer.ts @@ -5,12 +5,18 @@ import { tauriBackendService } from '@app/services/tauriBackendService'; /** * Hook to initialize backend and monitor health + * @param enabled - Whether to initialize the backend (default: true) */ -export function useBackendInitializer() { +export function useBackendInitializer(enabled = true) { const { status, checkHealth } = useBackendHealth(); const { backendUrl } = useEndpointConfig(); useEffect(() => { + // Skip if disabled + if (!enabled) { + return; + } + // Skip if backend already running if (tauriBackendService.isBackendRunning()) { void checkHealth(); @@ -36,5 +42,5 @@ export function useBackendInitializer() { if (status !== 'healthy' && status !== 'starting') { void initializeBackend(); } - }, [status, backendUrl, checkHealth]); + }, [enabled, status, backendUrl, checkHealth]); } diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts index d7acddd3d..cc81b4683 100644 --- a/frontend/src/desktop/services/operationRouter.ts +++ b/frontend/src/desktop/services/operationRouter.ts @@ -1,4 +1,4 @@ -import { connectionModeService, ConnectionMode } from './connectionModeService'; +import { connectionModeService } from '@app/services/connectionModeService'; export type ExecutionTarget = 'local' | 'remote'; @@ -14,10 +14,10 @@ export class OperationRouter { /** * Determines where an operation should execute - * @param operation - The operation name (for future operation classification) + * @param _operation - The operation name (for future operation classification) * @returns 'local' or 'remote' */ - async getExecutionTarget(operation?: string): Promise { + async getExecutionTarget(_operation?: string): Promise { const mode = await connectionModeService.getCurrentMode(); // Current implementation: simple mode-based routing @@ -37,11 +37,11 @@ export class OperationRouter { /** * Gets the base URL for an operation based on execution target - * @param operation - The operation name (for future operation classification) + * @param _operation - The operation name (for future operation classification) * @returns Base URL for API calls */ - async getBaseUrl(operation?: string): Promise { - const target = await this.getExecutionTarget(operation); + async getBaseUrl(_operation?: string): Promise { + const target = await this.getExecutionTarget(_operation); if (target === 'local') { return 'http://localhost:8080';