mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Desktop/remove hard requirement auth wall on desktop (#5956)
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
081b1ec49e
commit
2e2b55e87d
@@ -1,13 +1,17 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
||||
import { SaveShortcutListener } from '@app/components/SaveShortcutListener';
|
||||
import { SetupWizard } from '@app/components/SetupWizard';
|
||||
import { DesktopOnboardingModal } from '@app/components/DesktopOnboardingModal';
|
||||
import { SignInModal } from '@app/components/SignInModal';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
import { ToolActionsContext } from '@app/contexts/ToolActionsContext';
|
||||
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
|
||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { authService } from '@app/services/authService';
|
||||
@@ -41,25 +45,116 @@ const COMMON_TOOL_ENDPOINTS = [
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
|
||||
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | 'local' | null>(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
// Load connection mode on mount
|
||||
// When auth check finds no valid session, record the sign-in detail here so the
|
||||
// dispatch useEffect below can fire it only after SignInModal has mounted.
|
||||
const [pendingSignIn, setPendingSignIn] = useState<{ locked: boolean } | null>(null);
|
||||
// Prevent first-launch setup from running twice when connectionMode state update re-triggers the effect
|
||||
const firstLaunchInitiated = useRef(false);
|
||||
// Key incremented on every connection mode change after initial load — forces SaaS provider
|
||||
// tree to remount without a full page reload (avoids Windows WebView2 freeze on window.location.reload()).
|
||||
const [appKey, setAppKey] = useState(0);
|
||||
const hasLoadedInitialMode = useRef(false);
|
||||
|
||||
// Load connection mode on mount and subscribe to future changes
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
void connectionModeService.getCurrentMode().then((mode) => {
|
||||
setConnectionMode(mode);
|
||||
hasLoadedInitialMode.current = true;
|
||||
});
|
||||
|
||||
const unsub = connectionModeService.subscribeToModeChanges((config) => {
|
||||
setConnectionMode(config.mode);
|
||||
// Remount the SaaS provider tree when transitioning between saas/local modes so
|
||||
// Supabase client state is reset without a full page reload (avoids the Windows
|
||||
// WebView2 freeze that window.location.reload() causes during an OAuth flow).
|
||||
// Switching TO selfhosted skips the remount — self-hosted mode doesn't use the
|
||||
// SaaS providers and remounting mid-wizard resets authChecked, navigating away.
|
||||
// Switching FROM selfhosted TO saas DOES trigger a remount (mode !== 'selfhosted')
|
||||
// which is intentional — the SaaS provider tree needs fresh state after login.
|
||||
if (hasLoadedInitialMode.current && config.mode !== 'selfhosted') {
|
||||
setAppKey(k => k + 1);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait until connection mode is loaded before checking auth
|
||||
if (connectionMode === null) return;
|
||||
|
||||
if (!isFirstLaunch && setupComplete) {
|
||||
authService.isAuthenticated()
|
||||
.then(setIsAuthenticated)
|
||||
.catch(() => setIsAuthenticated(false))
|
||||
.finally(() => setAuthChecked(true));
|
||||
if (connectionMode === 'local') {
|
||||
// Even in local mode, check for a valid JWT — on Windows, the OAuth callback
|
||||
// can complete without switchToSaaS() being called (race condition), leaving
|
||||
// LOCAL_MODE_STORAGE_KEY set while the user has a valid session. Upgrade to
|
||||
// SaaS mode automatically so credits/billing/team features work correctly.
|
||||
authService.isAuthenticated()
|
||||
.then(async (isAuth) => {
|
||||
if (isAuth) {
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL).catch(console.error);
|
||||
setConnectionMode('saas');
|
||||
}
|
||||
})
|
||||
.finally(() => setAuthChecked(true));
|
||||
} else {
|
||||
let pendingDetail: { locked: boolean } | null = null;
|
||||
authService.isAuthenticated()
|
||||
.then(async (isAuth) => {
|
||||
if (!isAuth) {
|
||||
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
|
||||
if (cfg?.lock_connection_mode) {
|
||||
// Provisioned deployment — stay in the configured mode and prompt for credentials.
|
||||
// Don't fall back to local; the admin has locked the connection mode.
|
||||
pendingDetail = { locked: true };
|
||||
} else {
|
||||
// JWT expired — fall back to local so local tools still work, then prompt
|
||||
// for re-authentication via the sign-in modal.
|
||||
await connectionModeService.switchToLocal().catch(console.error);
|
||||
setConnectionMode('local');
|
||||
pendingDetail = { locked: false };
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(async () => {
|
||||
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
|
||||
if (cfg?.lock_connection_mode) {
|
||||
// Auth check threw (e.g. network error) but mode is locked — still prompt for
|
||||
// credentials so the user can sign in when connectivity is restored.
|
||||
pendingDetail = { locked: true };
|
||||
} else {
|
||||
await connectionModeService.switchToLocal().catch(console.error);
|
||||
setConnectionMode('local');
|
||||
pendingDetail = { locked: false };
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setAuthChecked(true);
|
||||
// Schedule sign-in via state so the dispatch useEffect fires AFTER
|
||||
// SignInModal mounts (children effects run before parent effects).
|
||||
if (pendingDetail) {
|
||||
setPendingSignIn(pendingDetail);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (isFirstLaunch && !setupComplete) {
|
||||
setAuthChecked(true);
|
||||
setIsAuthenticated(false);
|
||||
// Auto-enter local mode on first launch — skip the setup wizard entirely.
|
||||
// The onboarding carousel + sign-in toast will be shown inside the main app.
|
||||
// Start the backend explicitly here because shouldMonitorBackend relies on
|
||||
// setupComplete (still false from the hook), so useBackendInitializer won't fire.
|
||||
// Guard against re-running when setConnectionMode('local') below triggers this effect.
|
||||
if (firstLaunchInitiated.current) return;
|
||||
firstLaunchInitiated.current = true;
|
||||
connectionModeService.switchToLocal()
|
||||
.then(() => tauriBackendService.startBackend())
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setConnectionMode('local');
|
||||
setAuthChecked(true);
|
||||
});
|
||||
}
|
||||
}, [isFirstLaunch, setupComplete]);
|
||||
}, [isFirstLaunch, setupComplete, connectionMode]);
|
||||
|
||||
// Initialize backend health monitoring for self-hosted mode
|
||||
useEffect(() => {
|
||||
@@ -80,7 +175,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
|
||||
// Initialize monitoring for bundled backend (already started in Rust)
|
||||
// This sets up port detection and health checks
|
||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && (connectionMode === 'saas' || connectionMode === 'local');
|
||||
useBackendInitializer(shouldMonitorBackend);
|
||||
|
||||
// Preload endpoint availability for the local bundled backend.
|
||||
@@ -90,6 +185,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
// individual requests per-tool when the remote server goes offline).
|
||||
const shouldPreloadLocalEndpoints =
|
||||
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === 'local') ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
|
||||
useEffect(() => {
|
||||
if (!shouldPreloadLocalEndpoints) return;
|
||||
@@ -109,6 +205,15 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
return unsubscribe;
|
||||
}, [shouldPreloadLocalEndpoints, connectionMode]);
|
||||
|
||||
// Dispatch sign-in event only after authChecked=true so SignInModal is mounted.
|
||||
// Using useEffect (not setTimeout) guarantees child effects (SignInModal's listener
|
||||
// registration) run before this parent effect fires the event.
|
||||
useEffect(() => {
|
||||
if (!authChecked || !pendingSignIn) return;
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: pendingSignIn }));
|
||||
setPendingSignIn(null);
|
||||
}, [authChecked, pendingSignIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked) {
|
||||
return;
|
||||
@@ -145,58 +250,12 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup wizard on first launch
|
||||
if (isFirstLaunch && !setupComplete) {
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000,
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: 'non-blocking',
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup wizard when not authenticated (desktop login flow).
|
||||
if (authChecked && !isAuthenticated) {
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000,
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: 'non-blocking',
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal app flow
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000, // 1 second, with exponential backoff
|
||||
initialDelay: 1000,
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
@@ -204,17 +263,25 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<SaaSTeamProvider>
|
||||
<SaasBillingProvider>
|
||||
<SaaSCheckoutProvider>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
<CreditModalBootstrap />
|
||||
{children}
|
||||
</SaaSCheckoutProvider>
|
||||
</SaasBillingProvider>
|
||||
</SaaSTeamProvider>
|
||||
<ToolActionsContext.Provider value={{
|
||||
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
|
||||
}}>
|
||||
<SaaSTeamProvider key={appKey}>
|
||||
<SaasBillingProvider>
|
||||
<SaaSCheckoutProvider>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
<CreditModalBootstrap />
|
||||
{children}
|
||||
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
|
||||
<DesktopOnboardingModal />
|
||||
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
|
||||
<SignInModal />
|
||||
</SaaSCheckoutProvider>
|
||||
</SaasBillingProvider>
|
||||
</SaaSTeamProvider>
|
||||
</ToolActionsContext.Provider>
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
|
||||
export const ConnectionSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,31 +24,31 @@ export const ConnectionSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(loadConfig);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await authService.logout();
|
||||
|
||||
if (!config?.lock_connection_mode) {
|
||||
// Switch to SaaS mode
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
||||
|
||||
// Reset setup completion to force login screen on reload
|
||||
await connectionModeService.resetSetupCompletion();
|
||||
// Save server URL before clearing so user can easily reconnect (self-hosted only)
|
||||
if (config?.mode === 'selfhosted' && config?.server_config?.url) {
|
||||
localStorage.setItem('server_url', config.server_config.url);
|
||||
}
|
||||
await authService.logout();
|
||||
// Always switch to local after logout so the app remains usable
|
||||
await connectionModeService.switchToLocal();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Clear URL to home page before reload so we don't return to settings after re-login
|
||||
// Clear URL to home page 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();
|
||||
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||
// connectionModeService subscription when mode changes to local.
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
@@ -56,6 +56,10 @@ export const ConnectionSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignIn = () => {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return <Text>{t('common.loading', 'Loading...')}</Text>;
|
||||
}
|
||||
@@ -66,13 +70,27 @@ 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 === 'saas' ? 'blue' : 'green'} variant="light">
|
||||
<Badge
|
||||
color={config.mode === 'saas' ? 'blue' : config.mode === 'local' ? 'white' : 'green'}
|
||||
variant="light"
|
||||
>
|
||||
{config.mode === 'saas'
|
||||
? t('settings.connection.mode.saas', 'Stirling Cloud')
|
||||
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
|
||||
: config.mode === 'local'
|
||||
? t('settings.connection.mode.local', 'Local Only')
|
||||
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{config.mode === 'local' && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'settings.connection.localDescription',
|
||||
'You are using the local backend without an account. Some tools requiring cloud processing or a self-hosted server are unavailable.'
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
|
||||
<>
|
||||
<div>
|
||||
@@ -99,9 +117,15 @@ export const ConnectionSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
<Group mt="md">
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Log Out')}
|
||||
</Button>
|
||||
{config.mode === 'local' ? (
|
||||
<Button onClick={handleSignIn} color="blue" variant="light">
|
||||
{t('settings.connection.signIn', 'Sign In')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Log Out')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Modal, Stack, Group, Button, ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||
import { SetupWizard } from '@app/components/SetupWizard';
|
||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
|
||||
const ONBOARDING_KEY = 'stirling-desktop-onboarding-seen';
|
||||
|
||||
const SIGN_IN_GRADIENT: [string, string] = ['#3B82F6', '#7C3AED'];
|
||||
|
||||
/**
|
||||
* Desktop-specific onboarding modal.
|
||||
* Shown on first launch: welcome slide → sign-in slide.
|
||||
* Replaces the core onboarding (which targets server/admin users).
|
||||
*/
|
||||
export function DesktopOnboardingModal() {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(() => !localStorage.getItem(ONBOARDING_KEY));
|
||||
const [step, setStep] = useState(0);
|
||||
// null = still checking, true = locked (suppress modal), false = not locked (show modal)
|
||||
const [isLocked, setIsLocked] = useState<boolean | null>(null);
|
||||
|
||||
// Provisioned (locked) deployments skip the onboarding entirely — the non-dismissible
|
||||
// SignInModal handles authentication and shows the correct self-hosted login flow.
|
||||
useEffect(() => {
|
||||
connectionModeService.getCurrentConfig().then((cfg) => {
|
||||
setIsLocked(cfg.lock_connection_mode && !!cfg.server_config?.url);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dismissFinal = () => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
// X on slide 0 advances to sign-in slide rather than dismissing entirely
|
||||
const handleClose = () => {
|
||||
if (step === 0) {
|
||||
setStep(1);
|
||||
} else {
|
||||
dismissFinal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
setVisible(false);
|
||||
// No reload needed — AppProviders subscribes to connectionModeService and remounts
|
||||
// the SaaS provider tree when mode changes, avoiding the Windows WebView2 freeze
|
||||
// that window.location.reload() causes during a backgrounded OAuth flow.
|
||||
};
|
||||
|
||||
|
||||
// Call WelcomeSlide as a data factory (not a component render) — memoised so it
|
||||
// isn't reconstructed on every render while the modal is open.
|
||||
const welcomeSlide = useMemo(() => WelcomeSlide(), []);
|
||||
const totalSteps = 2;
|
||||
|
||||
if (!visible || isLocked === null || isLocked) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={visible}
|
||||
onClose={handleClose}
|
||||
closeOnClickOutside={step === 1}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
background: 'var(--bg-surface)',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Hero section — gradient changes per slide */}
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={(step === 0 ? welcomeSlide.background.gradientStops : SIGN_IN_GRADIENT) as [string, string]}
|
||||
circles={welcomeSlide.background.circles}
|
||||
isActive
|
||||
slideKey={step === 0 ? 'desktop-welcome' : 'desktop-sign-in'}
|
||||
/>
|
||||
<ActionIcon
|
||||
onClick={handleClose}
|
||||
radius="md"
|
||||
size={36}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
styles={{ root: { '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.3)' } } }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<div className={styles.heroLogo} key={`logo-${step}`}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{step === 0 ? (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
) : (
|
||||
<LocalIcon icon="login" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body section */}
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}
|
||||
>
|
||||
{step === 0 ? (
|
||||
// Welcome slide
|
||||
<Stack gap={16}>
|
||||
<div className={`${styles.title} ${styles.titleText}`}>
|
||||
{welcomeSlide.title}
|
||||
</div>
|
||||
<div className={styles.bodyText}>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{welcomeSlide.body}
|
||||
</div>
|
||||
<style>{`.${styles.bodyCopyInner} strong { color: var(--onboarding-title); font-weight: 600; }`}</style>
|
||||
</div>
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
|
||||
<div className={styles.buttonContainer}>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={() => setStep(1)}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('onboarding.buttons.next', 'Next →')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
// Sign-in slide
|
||||
<Stack gap={12}>
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
|
||||
<SetupWizard
|
||||
noLayout
|
||||
onComplete={handleComplete}
|
||||
onClose={dismissFinal}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ interface SaaSLoginScreenProps {
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onSelfHostedClick: () => void;
|
||||
onSwitchToSignup: () => void;
|
||||
onSkipSignIn?: () => void;
|
||||
onClose?: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -25,6 +27,8 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
onOAuthSuccess,
|
||||
onSelfHostedClick,
|
||||
onSwitchToSignup,
|
||||
onSkipSignIn,
|
||||
onClose,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
@@ -57,7 +61,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} />
|
||||
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} onClose={onClose} />
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
@@ -110,6 +114,19 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
</div>
|
||||
|
||||
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
||||
|
||||
{onSkipSignIn && (
|
||||
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkipSignIn}
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t('setup.login.skipSignIn', 'Continue without signing in')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,8 +35,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
url = `https://${url}`;
|
||||
setCustomUrl(url); // Update the input field
|
||||
}
|
||||
localStorage.setItem('server_url', url);
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -150,7 +148,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
console.error('[ServerSelection] Configuration fetch error details:', errorMessage);
|
||||
|
||||
setTestError(
|
||||
t('setup.server.error.configFetch', 'Failed to fetch server configuration: {{error}}', {
|
||||
t('setup.server.error.configFetchError', 'Failed to fetch server configuration: {{error}}', {
|
||||
error: errorMessage
|
||||
})
|
||||
);
|
||||
@@ -158,7 +156,8 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection successful - pass URL, OAuth providers, and login method
|
||||
// Connection successful — persist URL so it pre-fills on next sign-in
|
||||
localStorage.setItem('server_url', url);
|
||||
console.log('[ServerSelection] ✅ Server selection complete, proceeding to login');
|
||||
onSelect({
|
||||
url,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Button, Alert, Loader, Center } from '@mantine/core';
|
||||
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
||||
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
||||
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
|
||||
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
||||
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
||||
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
|
||||
import { ServerConfig, SSOProviderConfig, connectionModeService } from '@app/services/connectionModeService';
|
||||
import { AuthServiceError, authService, UserInfo } from '@app/services/authService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
@@ -21,9 +22,13 @@ enum SetupStep {
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: () => void;
|
||||
/** Omit the DesktopAuthLayout wrapper — use when rendering inside a modal */
|
||||
noLayout?: boolean;
|
||||
/** Called when the user dismisses the wizard (modal close button) */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout = false, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
|
||||
@@ -32,6 +37,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
|
||||
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
|
||||
const [lockConnectionMode, setLockConnectionMode] = useState(false);
|
||||
const [lockedServerUnreachable, setLockedServerUnreachable] = useState(false);
|
||||
const [lockedServerChecking, setLockedServerChecking] = useState(false);
|
||||
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
@@ -81,6 +88,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalMode = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Save the server URL so it pre-fills on reconnect
|
||||
if (serverConfig?.url) {
|
||||
localStorage.setItem('server_url', serverConfig.url);
|
||||
}
|
||||
await connectionModeService.switchToLocal();
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to continue in local mode:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelfHostedClick = () => {
|
||||
if (lockConnectionMode) {
|
||||
return;
|
||||
@@ -285,58 +310,73 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
if (currentConfig.lock_connection_mode && currentConfig.server_config?.url) {
|
||||
setLockConnectionMode(true);
|
||||
const loadLockedConfig = useCallback(async () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
if (!currentConfig.lock_connection_mode) return;
|
||||
// server_config may be null when the user switched to local mode from a locked deployment.
|
||||
// Fall back to the URL saved by switchToLocal() so the wizard still shows locked login.
|
||||
const serverUrl = currentConfig.server_config?.url
|
||||
|| localStorage.getItem('stirling-provisioned-server-url');
|
||||
if (!serverUrl) return;
|
||||
|
||||
// Re-fetch OAuth providers for the saved server URL
|
||||
const savedUrl = currentConfig.server_config.url.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
let updatedConfig = { ...currentConfig.server_config };
|
||||
setLockConnectionMode(true);
|
||||
setLockedServerUnreachable(false);
|
||||
setLockedServerChecking(true);
|
||||
|
||||
try {
|
||||
console.log('[SetupWizard] Re-fetching OAuth providers for saved server:', savedUrl);
|
||||
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
|
||||
const savedUrl = serverUrl.replace(/\/+$/, '');
|
||||
let updatedConfig: ServerConfig = { ...(currentConfig.server_config ?? { url: savedUrl }) };
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const enabledProviders: any[] = [];
|
||||
const providerEntries = Object.entries(data.providerList || {});
|
||||
try {
|
||||
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
|
||||
|
||||
providerEntries.forEach(([path, label]) => {
|
||||
const id = path.split('/').pop();
|
||||
if (id) {
|
||||
enabledProviders.push({
|
||||
id,
|
||||
path,
|
||||
label: typeof label === 'string' ? label : undefined,
|
||||
});
|
||||
}
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const enabledProviders: SSOProviderConfig[] = [];
|
||||
const providerEntries = Object.entries(data.providerList || {});
|
||||
|
||||
providerEntries.forEach(([path, label]) => {
|
||||
const id = path.split('/').pop();
|
||||
if (id) {
|
||||
enabledProviders.push({
|
||||
id,
|
||||
path,
|
||||
label: typeof label === 'string' ? label : undefined,
|
||||
});
|
||||
|
||||
updatedConfig = {
|
||||
...updatedConfig,
|
||||
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
|
||||
loginMethod: data.loginMethod || 'all',
|
||||
};
|
||||
|
||||
console.log('[SetupWizard] Updated config with OAuth providers:', updatedConfig);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SetupWizard] Failed to re-fetch OAuth providers:', err);
|
||||
}
|
||||
});
|
||||
|
||||
updatedConfig = {
|
||||
...updatedConfig,
|
||||
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
|
||||
loginMethod: data.loginMethod || 'all',
|
||||
};
|
||||
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
} else {
|
||||
// Server responded but with an error — still show login form
|
||||
updatedConfig = { ...updatedConfig, loginMethod: 'all' };
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
}
|
||||
};
|
||||
|
||||
void loadConfig();
|
||||
} catch (err) {
|
||||
// Network error — server is unreachable
|
||||
console.error('[SetupWizard] Server unreachable:', err);
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setLockedServerUnreachable(true);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DesktopAuthLayout>
|
||||
useEffect(() => {
|
||||
void loadLockedConfig();
|
||||
}, [loadLockedConfig]);
|
||||
|
||||
const wizardContent = (
|
||||
<>
|
||||
{/* Step Content */}
|
||||
{!lockConnectionMode && activeStep === SetupStep.SaaSLogin && (
|
||||
<SaaSLoginScreen
|
||||
@@ -345,6 +385,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||
onSelfHostedClick={handleSelfHostedClick}
|
||||
onSwitchToSignup={handleSwitchToSignup}
|
||||
onSkipSignIn={handleLocalMode}
|
||||
onClose={onClose}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
@@ -367,19 +409,68 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<SelfHostedLoginScreen
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
||||
loginMethod={serverConfig?.loginMethod}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
||||
mfaCode={selfHostedMfaCode}
|
||||
setMfaCode={setSelfHostedMfaCode}
|
||||
requiresMfa={selfHostedMfaRequired}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
{lockConnectionMode && lockedServerChecking && (
|
||||
<Center py="xl">
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SelfHostedLogin && lockedServerUnreachable && !lockedServerChecking && (
|
||||
<Stack gap="md" style={{ padding: '0.5rem 0' }}>
|
||||
<Alert color="orange" title={t('setup.selfhosted.unreachable.title', 'Cannot connect to server')}>
|
||||
<Text size="sm">
|
||||
{t('setup.selfhosted.unreachable.message', 'Could not reach {{url}}. Check that the server is running and accessible.', {
|
||||
url: serverConfig?.url,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
onClick={() => void loadLockedConfig()}
|
||||
>
|
||||
{t('setup.selfhosted.unreachable.retry', 'Retry')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="white"
|
||||
fullWidth
|
||||
onClick={handleLocalMode}
|
||||
>
|
||||
{t('setup.selfhosted.unreachable.continueOffline', 'Use local tools instead')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SelfHostedLogin && !lockedServerUnreachable && !lockedServerChecking && (
|
||||
<>
|
||||
<SelfHostedLoginScreen
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
||||
loginMethod={serverConfig?.loginMethod}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
||||
mfaCode={selfHostedMfaCode}
|
||||
setMfaCode={setSelfHostedMfaCode}
|
||||
requiresMfa={selfHostedMfaRequired}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
{lockConnectionMode && (
|
||||
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLocalMode}
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t('setup.selfhosted.switchToLocal', 'Use local tools instead')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Button */}
|
||||
@@ -394,6 +485,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (noLayout) {
|
||||
return <div style={{ padding: '2rem' }}>{wizardContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopAuthLayout>
|
||||
{wizardContent}
|
||||
</DesktopAuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal } from '@mantine/core';
|
||||
import { SetupWizard } from '@app/components/SetupWizard';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
import { Z_INDEX_SIGN_IN_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
export function SignInModal() {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setLocked(detail?.locked === true);
|
||||
setOpened(true);
|
||||
};
|
||||
window.addEventListener(OPEN_SIGN_IN_EVENT, handler);
|
||||
return () => window.removeEventListener(OPEN_SIGN_IN_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
if (!opened) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => { if (!locked) setOpened(false); }}
|
||||
size={520}
|
||||
centered
|
||||
withCloseButton={false}
|
||||
closeOnClickOutside={!locked}
|
||||
closeOnEscape={!locked}
|
||||
padding={0}
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_SIGN_IN_MODAL}
|
||||
>
|
||||
<SetupWizard
|
||||
noLayout
|
||||
onClose={() => setOpened(false)}
|
||||
onComplete={() => {
|
||||
setOpened(false);
|
||||
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||
// connectionModeService subscription when mode changes.
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,103 @@
|
||||
import { Box, rem } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Tooltip, rem, useComputedColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
|
||||
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
|
||||
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ConnectionStatusDot() {
|
||||
const { t } = useTranslation();
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
|
||||
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(
|
||||
() => selfHostedServerMonitor.getSnapshot()
|
||||
);
|
||||
const { isOnline, checkHealth } = useBackendHealth();
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges((config) => {
|
||||
setConnectionMode(config.mode);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return selfHostedServerMonitor.subscribe(setSelfHostedState);
|
||||
}, []);
|
||||
|
||||
const { label, color } = useMemo(() => {
|
||||
if (connectionMode === 'saas') {
|
||||
return {
|
||||
label: t('connectionMode.status.saas', 'Connected to Stirling Cloud'),
|
||||
color: '#3b82f6',
|
||||
};
|
||||
}
|
||||
if (connectionMode === 'selfhosted') {
|
||||
const serverOnline = selfHostedState.isOnline;
|
||||
const serverChecking = selfHostedState.status === 'checking';
|
||||
const backendLabel = serverChecking
|
||||
? t('connectionMode.status.selfhostedChecking', 'Connected to self-hosted server (checking...)')
|
||||
: serverOnline
|
||||
? t('connectionMode.status.selfhostedOnline', 'Connected to self-hosted server')
|
||||
: t('connectionMode.status.selfhostedOffline', 'Self-hosted server unreachable');
|
||||
return {
|
||||
label: backendLabel,
|
||||
color: serverChecking ? '#fcc419' : serverOnline ? '#37b24d' : '#e03131',
|
||||
};
|
||||
}
|
||||
// local
|
||||
return {
|
||||
label: isOnline
|
||||
? t('connectionMode.status.localOnline', 'Offline mode running')
|
||||
: t('connectionMode.status.localOffline', 'Offline mode running'),
|
||||
color: '#868e96',
|
||||
};
|
||||
}, [connectionMode, selfHostedState, isOnline, t]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={label}
|
||||
position="left"
|
||||
offset={12}
|
||||
withArrow
|
||||
withinPortal
|
||||
color={colorScheme === 'dark' ? undefined : 'dark'}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
role="status"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (connectionMode === 'local') {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
|
||||
} else {
|
||||
void checkHealth();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: rem(10),
|
||||
height: rem(10),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color,
|
||||
boxShadow: colorScheme === 'dark'
|
||||
? '0 0 0 2px rgba(255, 255, 255, 0.15)'
|
||||
: '0 0 0 2px rgba(0, 0, 0, 0.07)',
|
||||
display: 'inline-block',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box
|
||||
@@ -17,7 +110,7 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
||||
paddingBottom: rem(12),
|
||||
}}
|
||||
>
|
||||
<BackendHealthIndicator />
|
||||
<ConnectionStatusDot />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,10 @@ export function SelfHostedOfflineBanner() {
|
||||
() => !!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
|
||||
// Load connection mode once on mount
|
||||
// Load connection mode and keep it live via subscription
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||
}, []);
|
||||
|
||||
// Subscribe to self-hosted server status changes
|
||||
|
||||
@@ -20,23 +20,12 @@ export const useConfigNavSections = (
|
||||
): ConfigNavSection[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Check if in SaaS mode and authenticated (for Team section visibility)
|
||||
const [isSaasMode, setIsSaasMode] = useState<boolean>(false);
|
||||
const [connectionMode, setConnectionMode] = useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === 'saas');
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
|
||||
// Subscribe to connection mode changes
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
|
||||
return unsubscribe;
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
@@ -47,9 +36,33 @@ export const useConfigNavSections = (
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const isSaasMode = connectionMode === 'saas';
|
||||
const isLocalMode = connectionMode === 'local';
|
||||
|
||||
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
|
||||
const connectionModeSection: ConfigNavSection = {
|
||||
title: t('settings.connection.title', 'Connection Mode'),
|
||||
items: [
|
||||
{
|
||||
key: 'connectionMode',
|
||||
label: t('settings.connection.title', 'Connection Mode'),
|
||||
icon: 'desktop-cloud-rounded',
|
||||
component: <ConnectionSettings />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// In local mode only show Preferences + Connection Mode — everything else
|
||||
// requires a server and will 500 or show irrelevant admin UI.
|
||||
if (isLocalMode) {
|
||||
const result: ConfigNavSection[] = [];
|
||||
if (sections.length > 0) result.push(sections[0]);
|
||||
result.push(connectionModeSection);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Identifies self-hosted admin sections by their first item's stable key.
|
||||
// Using item keys avoids dependency on translated section titles (#17).
|
||||
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
|
||||
@@ -67,17 +80,7 @@ export const useConfigNavSections = (
|
||||
if (sections.length > 0) result.push(sections[0]);
|
||||
|
||||
// Connection Mode always sits immediately after Preferences
|
||||
result.push({
|
||||
title: t('settings.connection.title', 'Connection Mode'),
|
||||
items: [
|
||||
{
|
||||
key: 'connectionMode',
|
||||
label: t('settings.connection.title', 'Connection Mode'),
|
||||
icon: 'desktop-cloud-rounded',
|
||||
component: <ConnectionSettings />,
|
||||
},
|
||||
],
|
||||
});
|
||||
result.push(connectionModeSection);
|
||||
|
||||
// Plan & Billing and Team sections only when authenticated in SaaS mode
|
||||
if (isSaasMode && isAuthenticated) {
|
||||
@@ -106,12 +109,17 @@ export const useConfigNavSections = (
|
||||
}
|
||||
|
||||
// Append remaining proprietary sections, skipping self-hosted admin sections in SaaS mode
|
||||
// and hiding the Account section when not authenticated.
|
||||
for (const section of sections.slice(1)) {
|
||||
const firstItemKey = section.items[0]?.key;
|
||||
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
|
||||
continue;
|
||||
}
|
||||
result.push(section);
|
||||
const filteredItems = isAuthenticated
|
||||
? section.items
|
||||
: section.items.filter(item => item.key !== 'account');
|
||||
if (filteredItems.length === 0) continue;
|
||||
result.push({ ...section, items: filteredItems });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -54,7 +54,9 @@ export function CreditModalBootstrap() {
|
||||
toolId: customEvent.detail?.operationType,
|
||||
requiredCredits: customEvent.detail?.requiredCredits,
|
||||
});
|
||||
setInsufficientOpen(true);
|
||||
// Show the plans banner (CreditExhaustedModal) instead of the simpler
|
||||
// InsufficientCreditsModal — same experience as clicking the upgrade button.
|
||||
setExhaustedOpen(true);
|
||||
};
|
||||
|
||||
window.addEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import CoreToolButton from '@core/components/tools/toolPicker/ToolButton';
|
||||
import { getToolDisabledReason } from '@app/components/tools/fullscreen/shared';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
|
||||
type CoreToolButtonProps = React.ComponentProps<typeof CoreToolButton>;
|
||||
|
||||
/**
|
||||
* Desktop override of ToolButton.
|
||||
* In local mode, unavailable tools (except comingSoon/selfHostedOffline) navigate directly
|
||||
* to the tool UI — the execute button there shows the disabled state with a "click to sign in"
|
||||
* tooltip, keeping the tool's settings visible and letting the user explore before committing.
|
||||
* In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge).
|
||||
*/
|
||||
const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
|
||||
const { toolAvailability, handleToolSelectForced } = useToolWorkflow();
|
||||
const { config } = useAppConfig();
|
||||
const premiumEnabled = config?.premiumEnabled;
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges((cfg) => setConnectionMode(cfg.mode));
|
||||
}, []);
|
||||
|
||||
const disabledReason = getToolDisabledReason(
|
||||
props.id as string,
|
||||
props.tool as ToolRegistryEntry,
|
||||
toolAvailability,
|
||||
premiumEnabled
|
||||
);
|
||||
|
||||
// In local mode, pass a handler so CoreToolButton renders the tool as "cloud-available"
|
||||
// (full opacity, cloud badge, clickable). Clicking navigates to the tool normally so the
|
||||
// user can see the settings; the disabled execute button handles the sign-in prompt.
|
||||
// comingSoon and selfHostedOffline tools remain dimmed — they have no usable UI to show.
|
||||
const handleUnavailableClick =
|
||||
connectionMode === 'local' &&
|
||||
disabledReason !== 'comingSoon' &&
|
||||
disabledReason !== 'selfHostedOffline'
|
||||
? () => handleToolSelectForced(props.id as ToolId)
|
||||
: undefined;
|
||||
|
||||
return <CoreToolButton {...props} onUnavailableClick={handleUnavailableClick} />;
|
||||
};
|
||||
|
||||
export default ToolButton;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Group, Text, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
|
||||
/**
|
||||
* Desktop-only footer shown at the bottom of the tool list.
|
||||
* In local (offline) mode: prompts the user to sign in to unlock cloud tools.
|
||||
* In other modes: renders nothing.
|
||||
*/
|
||||
export function ToolPickerFooterExtensions() {
|
||||
const { t } = useTranslation();
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges((config) => {
|
||||
setConnectionMode(config.mode);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
if (connectionMode !== 'local') return null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py={10}
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-default)',
|
||||
background: 'var(--bg-toolbar)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" style={{ flex: 1, minWidth: 0 }}>
|
||||
{t('localMode.toolPicker.message', 'Sign in to unlock all tools.')}
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))}
|
||||
>
|
||||
{t('localMode.toolPicker.signIn', 'Sign In')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* CustomEvent name for opening the desktop sign-in modal (SetupWizard).
|
||||
* Kept in a leaf module so apiClientSetup and others avoid importing SignInModal (heavy graph).
|
||||
*/
|
||||
export const OPEN_SIGN_IN_EVENT = 'stirling:open-sign-in';
|
||||
@@ -118,7 +118,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<Team[]>('/api/v1/team/my');
|
||||
const response = await apiClient.get<Team[]>('/api/v1/team/my', { suppressErrorToast: true });
|
||||
setTeams(response.data);
|
||||
|
||||
const activeTeam = response.data[0];
|
||||
@@ -144,7 +144,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`);
|
||||
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`, { suppressErrorToast: true });
|
||||
setTeamMembers(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
|
||||
@@ -158,7 +158,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`);
|
||||
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`, { suppressErrorToast: true });
|
||||
setTeamInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch team invitations:', error);
|
||||
@@ -174,7 +174,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
console.log('[SaaSTeamContext] Fetching received team invitations');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending');
|
||||
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending', { suppressErrorToast: true });
|
||||
console.log('[SaaSTeamContext] Received invitations response:', response.data);
|
||||
setReceivedInvitations(response.data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
@@ -17,13 +16,16 @@ export function useAccountLogout() {
|
||||
await signOut();
|
||||
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
if (!currentConfig.lock_connection_mode) {
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
||||
await connectionModeService.resetSetupCompletion().catch(() => {});
|
||||
// Save server URL before clearing so user can easily reconnect (self-hosted only)
|
||||
if (currentConfig.mode === 'selfhosted' && currentConfig.server_config?.url) {
|
||||
localStorage.setItem('server_url', currentConfig.server_config.url);
|
||||
}
|
||||
// Always switch to local after logout so the app remains usable
|
||||
await connectionModeService.switchToLocal();
|
||||
|
||||
window.history.replaceState({}, '', '/');
|
||||
window.location.reload();
|
||||
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||
// connectionModeService subscription when mode changes to local.
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
|
||||
|
||||
@@ -30,6 +30,7 @@ export function useBackendHealth() {
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -106,13 +106,13 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
|
||||
const locallyEnabled = response.data;
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
|
||||
// Even if not supported locally, they will route to SaaS backend
|
||||
if (!locallyEnabled) {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
|
||||
// Even if not supported locally, they will route to SaaS backend
|
||||
if (mode === 'saas') {
|
||||
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
|
||||
setEnabled(true); // Available via SaaS
|
||||
setEnabled(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -302,9 +302,10 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
|
||||
// They will route to SaaS backend
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'saas') {
|
||||
const disabledEndpoints = Object.keys(details).filter(key => !details[key].enabled);
|
||||
|
||||
@@ -315,6 +316,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import HomePage from '@app/pages/HomePage';
|
||||
|
||||
/**
|
||||
* Desktop override of Landing.
|
||||
* In desktop builds, authentication is managed entirely by AppProviders,
|
||||
* the DesktopOnboardingModal, and the SignInModal — never by routing to /login.
|
||||
* Always render the main app; the onboarding/sign-in modals appear on top
|
||||
* when authentication is required.
|
||||
*/
|
||||
export default function Landing() {
|
||||
return <HomePage />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Desktop override of the /login route.
|
||||
* The legacy web login page must never appear in desktop builds — authentication
|
||||
* is handled exclusively through the DesktopOnboardingModal and SignInModal.
|
||||
* Any navigation to /login (e.g. from Spring Boot auth redirects) is intercepted
|
||||
* here and immediately redirected to /.
|
||||
* The sign-in modal is opened by the desktop httpErrorHandler before navigation
|
||||
* occurs, so no additional dispatch is needed here.
|
||||
*/
|
||||
export default function Login() {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
|
||||
interface LoginHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
centerOnly?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop override of LoginHeader.
|
||||
* Renders icon + title + optional close button all in one row.
|
||||
*/
|
||||
export default function LoginHeader({ title, subtitle, centerOnly = false, onClose }: LoginHeaderProps) {
|
||||
const { tooltipLogo } = useLogoAssets();
|
||||
|
||||
return (
|
||||
<div className={`login-header${centerOnly ? ' login-header-centered' : ''}`} style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flex: 1, minWidth: 0 }}>
|
||||
<img src={tooltipLogo} alt="Stirling PDF" style={{ width: 36, height: 36, flexShrink: 0 }} />
|
||||
{title && <h1 className="login-title" style={{ margin: 0 }}>{title}</h1>}
|
||||
</div>
|
||||
{onClose && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
radius="md"
|
||||
size={32}
|
||||
variant="subtle"
|
||||
style={{ flexShrink: 0, color: 'var(--text-secondary)', outline: 'none' }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <p className="login-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { operationRouter } from '@app/services/operationRouter';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
import i18n from '@app/i18n';
|
||||
|
||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||
@@ -52,8 +53,6 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
|
||||
}
|
||||
|
||||
localStorage.setItem('server_url', baseUrl);
|
||||
|
||||
// Debug logging
|
||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||
|
||||
@@ -162,6 +161,12 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
if (originalRequest.skipAuthRedirect) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// If no Authorization header was sent, the user was never authenticated —
|
||||
// the 401 is expected (e.g. endpoint availability checks when not signed in).
|
||||
// Don't attempt a refresh or open the sign-in modal in that case.
|
||||
if (!originalRequest.headers.Authorization) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
originalRequest._retry = true;
|
||||
|
||||
console.debug(`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`);
|
||||
@@ -194,13 +199,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
return client.request(originalRequest);
|
||||
}
|
||||
|
||||
// Refresh failed - user needs to login again
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('auth.sessionExpired', 'Session Expired'),
|
||||
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
// Refresh failed - prompt for re-authentication via the sign-in modal.
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }));
|
||||
}
|
||||
|
||||
// Handle 403 Forbidden - unauthorized access
|
||||
|
||||
@@ -192,6 +192,22 @@ export class AuthService {
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only: treat any stored JWT as expired so cold start and auth checks mimic
|
||||
* "access token dead" (local fallback + sign-in nudge) without editing storage by hand.
|
||||
* Enable with VITE_DEV_SIMULATE_EXPIRED_JWT=true in .env.desktop — only works in dev builds.
|
||||
*/
|
||||
private shouldSimulateExpiredJwt(): boolean {
|
||||
// Stop simulating once the user has freshly authenticated in this session
|
||||
// (e.g. after completing re-auth via the sign-in modal). This prevents the
|
||||
// simulation from looping: expired → modal → re-auth → expired → modal…
|
||||
if (this.authStatus === 'authenticated') return false;
|
||||
return (
|
||||
import.meta.env.DEV &&
|
||||
String(import.meta.env.VITE_DEV_SIMULATE_EXPIRED_JWT ?? '').toLowerCase() === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
|
||||
if (!accessToken || !accessToken.trim()) {
|
||||
throw new Error('Invalid access token');
|
||||
@@ -455,6 +471,12 @@ export class AuthService {
|
||||
|
||||
// Cache token if found (backend will validate expiry)
|
||||
if (token && token.trim().length > 0) {
|
||||
if (this.shouldSimulateExpiredJwt()) {
|
||||
console.warn(
|
||||
'[Desktop AuthService] DEV: VITE_DEV_SIMULATE_EXPIRED_JWT — ignoring stored access token (simulates expiry)'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
this.cachedToken = token;
|
||||
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
||||
return token;
|
||||
@@ -507,6 +529,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
isTokenExpiringSoon(token: string, leewaySeconds = 30): boolean {
|
||||
if (this.shouldSimulateExpiredJwt()) {
|
||||
console.warn(
|
||||
'[Desktop AuthService] DEV: VITE_DEV_SIMULATE_EXPIRED_JWT — treating token as expired (isTokenExpiringSoon)'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
@@ -718,8 +746,11 @@ export class AuthService {
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
console.log('[Desktop AuthService] Auth state initialized as unauthenticated');
|
||||
|
||||
// Defensive: ensure any partial tokens are purged to prevent auto-login loops
|
||||
await this.clearTokenEverywhere().catch(() => {});
|
||||
// Defensive: ensure any partial tokens are purged to prevent auto-login loops.
|
||||
// Skip when simulating expiry — the token is real and must not be destroyed.
|
||||
if (!this.shouldSimulateExpiredJwt()) {
|
||||
await this.clearTokenEverywhere().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
|
||||
export type ConnectionMode = 'saas' | 'selfhosted';
|
||||
export type ConnectionMode = 'saas' | 'selfhosted' | 'local';
|
||||
|
||||
export interface SSOProviderConfig {
|
||||
id: string;
|
||||
@@ -36,6 +36,8 @@ export interface ConnectionTestResult {
|
||||
diagnostics?: DiagnosticResult[];
|
||||
}
|
||||
|
||||
export const LOCAL_MODE_STORAGE_KEY = 'stirling-local-mode';
|
||||
|
||||
export class ConnectionModeService {
|
||||
private static instance: ConnectionModeService;
|
||||
private currentConfig: ConnectionConfig | null = null;
|
||||
@@ -82,12 +84,35 @@ export class ConnectionModeService {
|
||||
private async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const config = await invoke<ConnectionConfig>('get_connection_config');
|
||||
|
||||
const localFlag = localStorage.getItem(LOCAL_MODE_STORAGE_KEY);
|
||||
|
||||
if (config.mode === 'saas' && localFlag === 'true') {
|
||||
// User previously chose local-only mode.
|
||||
config.mode = 'local';
|
||||
} else if (
|
||||
config.mode === 'saas' &&
|
||||
config.server_config === null &&
|
||||
!config.lock_connection_mode &&
|
||||
localFlag === null
|
||||
) {
|
||||
// Fresh install: Rust has never been given a server URL, the connection
|
||||
// mode has never been explicitly set (no localStorage flag either direction),
|
||||
// and there is no provisioning lock. Default to local so the user sees
|
||||
// the bundled backend instead of a broken SaaS-mode UI.
|
||||
// MSI installs with STIRLING_SERVER_URL are excluded because they have a
|
||||
// non-null server_config; locked provisioned installs are excluded by the
|
||||
// lock_connection_mode guard.
|
||||
config.mode = 'local';
|
||||
}
|
||||
|
||||
this.currentConfig = config;
|
||||
this.configLoadedOnce = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load connection config:', error);
|
||||
// Default to SaaS mode on error
|
||||
this.currentConfig = { mode: 'saas', server_config: null, lock_connection_mode: false };
|
||||
// Default to local mode on error — safer than showing SaaS UI for a
|
||||
// desktop app whose bundled backend is always available.
|
||||
this.currentConfig = { mode: 'local', server_config: null, lock_connection_mode: false };
|
||||
this.configLoadedOnce = true;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +122,9 @@ export class ConnectionModeService {
|
||||
throw new Error('Connection mode is locked by provisioning');
|
||||
}
|
||||
|
||||
// Clear local-only flag if switching to a real account
|
||||
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
|
||||
|
||||
console.log('Switching to SaaS mode');
|
||||
|
||||
const serverConfig: ServerConfig = { url: saasServerUrl };
|
||||
@@ -117,7 +145,37 @@ export class ConnectionModeService {
|
||||
console.log('Switched to SaaS mode successfully');
|
||||
}
|
||||
|
||||
async switchToLocal(): Promise<void> {
|
||||
console.log('Switching to local-only mode');
|
||||
|
||||
// Persist local mode preference via localStorage so no Rust enum change is needed.
|
||||
// The Rust store records this as 'saas' (same bundled-backend behaviour); we overlay
|
||||
// the 'local' distinction purely on the TypeScript side.
|
||||
localStorage.setItem(LOCAL_MODE_STORAGE_KEY, 'true');
|
||||
|
||||
// When a locked provisioned deployment falls back to local, preserve the server URL
|
||||
// so the SetupWizard can pre-fill it if the user tries to sign in again.
|
||||
if (this.currentConfig?.lock_connection_mode && this.currentConfig.server_config?.url) {
|
||||
localStorage.setItem('stirling-provisioned-server-url', this.currentConfig.server_config.url);
|
||||
}
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'saas',
|
||||
serverConfig: null,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'local', server_config: null, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
|
||||
|
||||
// Clear endpoint availability cache when mode changes
|
||||
endpointAvailabilityService.clearCache();
|
||||
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
|
||||
// Clear local-only flag if switching to a real account
|
||||
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
|
||||
|
||||
console.log('Switching to self-hosted mode:', serverConfig);
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { handleHttpError as coreHandleHttpError } from '@core/services/httpErrorHandler';
|
||||
|
||||
/**
|
||||
* Desktop override of handleHttpError.
|
||||
* In desktop builds, 401 errors must never navigate to /login — the legacy web
|
||||
* login page must not appear. Instead, open the SignInModal for re-authentication.
|
||||
* All other error handling delegates to the core implementation.
|
||||
*/
|
||||
export async function handleHttpError(error: any): Promise<boolean> {
|
||||
const status: number | undefined = error?.response?.status;
|
||||
|
||||
if (status === 401) {
|
||||
// In desktop builds, 401s are handled by the auth service (token refresh + toast
|
||||
// shown by apiClientSetup). Authentication is done via the onboarding modal or
|
||||
// SignInModal — never by navigating to /login or opening a popup here.
|
||||
return true; // Suppress toast
|
||||
}
|
||||
|
||||
return coreHandleHttpError(error);
|
||||
}
|
||||
@@ -27,9 +27,7 @@ export class OperationRouter {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Current implementation: simple mode-based routing
|
||||
if (mode === 'saas') {
|
||||
// SaaS mode: For now, all operations run locally
|
||||
// Future enhancement: complex operations will be sent to SaaS server
|
||||
if (mode === 'saas' || mode === 'local') {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
@@ -133,6 +131,35 @@ export class OperationRouter {
|
||||
async getBaseUrl(operation?: string): Promise<string> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Local-only mode: route everything to local backend; open settings if tool unavailable
|
||||
if (mode === 'local') {
|
||||
if (operation && this.isToolEndpoint(operation)) {
|
||||
const endpointName = this.extractEndpointName(operation);
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
if (backendUrl) {
|
||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointName,
|
||||
backendUrl
|
||||
);
|
||||
if (!supportedLocally) {
|
||||
// Open the connection settings so the user can sign in
|
||||
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'connectionMode' } }));
|
||||
throw new Error(
|
||||
i18n.t(
|
||||
'localMode.toolUnavailable',
|
||||
'This tool requires an account. Sign in to Stirling Cloud or connect to a self-hosted server to use it.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
if (!backendUrl) {
|
||||
throw new Error('Backend URL not available - backend may still be starting');
|
||||
}
|
||||
return backendUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// Always route team endpoints to SaaS backend (existing logic)
|
||||
if (mode === 'saas' && this.isSaaSBackendEndpoint(operation)) {
|
||||
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
||||
@@ -148,39 +175,47 @@ export class OperationRouter {
|
||||
const endpointToCheck = this.extractEndpointName(operation);
|
||||
console.debug(`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`);
|
||||
|
||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointToCheck,
|
||||
tauriBackendService.getBackendUrl()
|
||||
);
|
||||
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
const backendHealthy = tauriBackendService.isOnline;
|
||||
|
||||
if (!supportedLocally) {
|
||||
// Local backend doesn't support this - check if SaaS supports it
|
||||
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
|
||||
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
|
||||
// If the local backend isn't ready (no URL yet, or not yet healthy), skip the
|
||||
// capability check and fall through to local routing — the backend-readiness check
|
||||
// in the Axios interceptor will block non-GET requests until the backend is healthy.
|
||||
if (backendUrl && backendHealthy) {
|
||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointToCheck,
|
||||
backendUrl
|
||||
);
|
||||
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
|
||||
|
||||
if (!supportedOnSaaS) {
|
||||
// Neither local nor SaaS support this - throw error
|
||||
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
|
||||
throw new Error(
|
||||
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`
|
||||
);
|
||||
if (!supportedLocally) {
|
||||
// Local backend doesn't support this - check if SaaS supports it
|
||||
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
|
||||
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
|
||||
|
||||
if (!supportedOnSaaS) {
|
||||
// Neither local nor SaaS support this - throw error
|
||||
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
|
||||
throw new Error(
|
||||
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`
|
||||
);
|
||||
}
|
||||
|
||||
// SaaS supports it - route to SaaS backend
|
||||
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
||||
console.error('[operationRouter] VITE_SAAS_BACKEND_API_URL not configured');
|
||||
throw new Error(
|
||||
'Cloud processing is required for this tool but VITE_SAAS_BACKEND_API_URL is not configured. ' +
|
||||
'Please check your environment configuration.'
|
||||
);
|
||||
}
|
||||
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (not supported locally, but supported on SaaS)`);
|
||||
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// SaaS supports it - route to SaaS backend
|
||||
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
||||
console.error('[operationRouter] VITE_SAAS_BACKEND_API_URL not configured');
|
||||
throw new Error(
|
||||
'Cloud processing is required for this tool but VITE_SAAS_BACKEND_API_URL is not configured. ' +
|
||||
'Please check your environment configuration.'
|
||||
);
|
||||
}
|
||||
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (not supported locally, but supported on SaaS)`);
|
||||
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
|
||||
// Supported locally - continue with local backend
|
||||
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
|
||||
}
|
||||
|
||||
// Supported locally - continue with local backend
|
||||
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
|
||||
}
|
||||
|
||||
// Self-hosted fallback: when the remote server is offline, route tool endpoints
|
||||
@@ -268,11 +303,13 @@ export class OperationRouter {
|
||||
// NEW: Skip if endpoint will be routed to SaaS due to local unavailability
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'saas' && endpoint && this.isToolEndpoint(endpoint)) {
|
||||
// For UI data endpoints, extract the endpoint name
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
// Backend not ready — don't skip the readiness check; let it gate the request.
|
||||
if (!backendUrl || !tauriBackendService.isOnline) return false;
|
||||
const endpointToCheck = this.extractEndpointName(endpoint);
|
||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointToCheck,
|
||||
tauriBackendService.getBackendUrl()
|
||||
backendUrl
|
||||
);
|
||||
return !supportedLocally; // Skip check if not supported locally
|
||||
}
|
||||
@@ -288,7 +325,9 @@ export class OperationRouter {
|
||||
*/
|
||||
async willRouteToSaaS(endpoint: string): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== 'saas') return false;
|
||||
// In local mode, show cloud badge for tools not supported locally
|
||||
// (clicking them will prompt sign-in via onUnavailableClick)
|
||||
if (mode !== 'saas' && mode !== 'local') return false;
|
||||
|
||||
// Team endpoints always go to SaaS
|
||||
if (this.isSaaSBackendEndpoint(endpoint)) return true;
|
||||
|
||||
@@ -104,7 +104,8 @@ class SelfHostedServerMonitor {
|
||||
connectTimeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 401/403 means the server is running but requires authentication — treat as online
|
||||
if (response.ok || response.status === 401 || response.status === 403) {
|
||||
this.updateState({ status: 'online', isOnline: true });
|
||||
} else {
|
||||
this.updateState({ status: 'offline', isOnline: false });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { alert } from '@app/components/toast';
|
||||
|
||||
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||
|
||||
@@ -11,6 +12,12 @@ export class TauriBackendService {
|
||||
private healthMonitor: Promise<void> | null = null;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private statusListeners = new Set<(status: BackendStatus) => void>();
|
||||
/** True when we own the backend process (startBackend called, not initializeExternalBackend) */
|
||||
private isLocalBackend = false;
|
||||
private recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRecovering = false;
|
||||
private restartAttempts = 0;
|
||||
private static readonly MAX_RESTART_ATTEMPTS = 3;
|
||||
|
||||
static getInstance(): TauriBackendService {
|
||||
if (!TauriBackendService.instance) {
|
||||
@@ -52,6 +59,79 @@ export class TauriBackendService {
|
||||
}
|
||||
this.backendStatus = status;
|
||||
this.statusListeners.forEach(listener => listener(status));
|
||||
|
||||
if (status === 'healthy') {
|
||||
// Reset restart counter on successful recovery so future failures get a full retry budget.
|
||||
this.restartAttempts = 0;
|
||||
}
|
||||
|
||||
// Auto-recovery: when our own backend goes unhealthy, try restarting it
|
||||
// before reporting as permanently offline.
|
||||
if (status === 'unhealthy' && this.isLocalBackend && !this.isRecovering) {
|
||||
this.scheduleRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRecovery() {
|
||||
if (this.recoveryTimer) return;
|
||||
// Give it a 2s grace period — transient failures (e.g. during logout/reload)
|
||||
// should resolve on their own before we attempt a full restart.
|
||||
this.recoveryTimer = setTimeout(() => {
|
||||
this.recoveryTimer = null;
|
||||
if (this.backendStatus !== 'unhealthy') return; // Recovered on its own
|
||||
void this.attemptRestart();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async attemptRestart(): Promise<void> {
|
||||
if (this.isRecovering) return;
|
||||
if (this.restartAttempts >= TauriBackendService.MAX_RESTART_ATTEMPTS) {
|
||||
console.error(`[TauriBackendService] Backend failed after ${TauriBackendService.MAX_RESTART_ATTEMPTS} restart attempts, giving up.`);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Backend failed to restart',
|
||||
body: 'The local backend could not be recovered. Please restart the app.',
|
||||
isPersistentPopup: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.restartAttempts++;
|
||||
console.log(`[TauriBackendService] Backend unhealthy, attempting restart (${this.restartAttempts}/${TauriBackendService.MAX_RESTART_ATTEMPTS})...`);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: 'Backend stopped unexpectedly',
|
||||
body: `Attempting to restart... (${this.restartAttempts}/${TauriBackendService.MAX_RESTART_ATTEMPTS})`,
|
||||
durationMs: 5000,
|
||||
});
|
||||
this.isRecovering = true;
|
||||
// Reset started flag so startBackend() will run again
|
||||
this.backendStarted = false;
|
||||
this.startPromise = null;
|
||||
this.setStatus('starting');
|
||||
try {
|
||||
await this.startBackend();
|
||||
this.restartAttempts = 0; // Reset on successful restart
|
||||
this.isRecovering = false;
|
||||
console.log('[TauriBackendService] Backend restarted successfully.');
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'Backend restarted',
|
||||
body: 'The local backend is back online.',
|
||||
durationMs: 4000,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TauriBackendService] Restart failed:', err);
|
||||
// Set isRecovering = false BEFORE setStatus to prevent re-triggering scheduleRecovery
|
||||
// if the max attempts check above doesn't catch it next time.
|
||||
this.isRecovering = false;
|
||||
if (this.restartAttempts < TauriBackendService.MAX_RESTART_ATTEMPTS) {
|
||||
this.setStatus('unhealthy'); // Will trigger another scheduleRecovery
|
||||
} else {
|
||||
// Don't call setStatus('unhealthy') — the status is already unhealthy and calling it
|
||||
// again would bypass the dedup check and re-trigger scheduleRecovery.
|
||||
console.error('[TauriBackendService] Max restart attempts reached, backend is permanently unhealthy.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +160,8 @@ export class TauriBackendService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLocalBackend = true; // We own this backend process
|
||||
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
@@ -190,6 +272,13 @@ export class TauriBackendService {
|
||||
reset(): void {
|
||||
this.backendStarted = false;
|
||||
this.backendPort = null;
|
||||
this.isLocalBackend = false;
|
||||
this.isRecovering = false;
|
||||
this.restartAttempts = 0;
|
||||
if (this.recoveryTimer) {
|
||||
clearTimeout(this.recoveryTimer);
|
||||
this.recoveryTimer = null;
|
||||
}
|
||||
this.setStatus('stopped');
|
||||
this.healthMonitor = null;
|
||||
this.startPromise = null;
|
||||
|
||||
Reference in New Issue
Block a user