Bug/connection mode fixes (#5998)

This commit is contained in:
EthanHealy01
2026-04-01 15:33:46 +01:00
committed by GitHub
parent ecd1d3cad3
commit 74153b6deb
10 changed files with 211 additions and 92 deletions
+1 -1
View File
@@ -10,4 +10,4 @@ VITE_SAAS_BACKEND_API_URL=https://api2.stirling.com
# Dev only: set to true to mimic an expired access token (no valid JWT for API/auth checks).
# Production builds ignore this. Restart tauri-dev after changing.
VITE_DEV_SIMULATE_EXPIRED_JWT=false
VITE_DEV_SIMULATE_EXPIRED_JWT=
@@ -6817,10 +6817,13 @@ title = "Sign in to Stirling"
[setup.selfhosted]
link = "or connect to a self-hosted account"
subtitle = "Enter your server credentials"
changeServerLocked = "Your organisation has restricted this app to a specific server"
switchToLocal = "Use local tools instead"
title = "Sign in to Server"
[setup.selfhosted.unreachable]
changeServer = "Connect to a different server"
changeServerLocked = "Your organisation has restricted this app to a specific server"
continueOffline = "Use local tools instead"
message = "Could not reach {{url}}. Check that the server is running and accessible."
retry = "Retry"
+22 -5
View File
@@ -72,6 +72,28 @@ pub async fn set_connection_mode(
) -> Result<(), String> {
log::info!("Setting connection mode: {:?}", mode);
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
// If the store is already locked, protect connection_mode, server_config, and the lock
// flag from being overwritten by any JS-side call.
// Only allow marking setup_completed and updating auth-related fields.
let already_locked = store
.get(LOCK_CONNECTION_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already_locked {
log::warn!("set_connection_mode called while lock_connection_mode=true — preserving connection settings, but marking setup as completed");
// Still allow setup_completed to be written so the onboarding doesn't repeat.
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
return Ok(());
}
// Update in-memory state
if let Ok(mut conn_state) = state.0.lock() {
conn_state.mode = mode.clone();
@@ -81,11 +103,6 @@ pub async fn set_connection_mode(
}
}
// Save to store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
CONNECTION_MODE_KEY,
serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?,
@@ -10,7 +10,7 @@ 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 { connectionModeService, JWT_EXPIRED_PROMPTED_KEY } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
@@ -47,9 +47,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | 'local' | null>(null);
const [authChecked, setAuthChecked] = useState(false);
// 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);
const [pendingSignIn, setPendingSignIn] = useState(false);
// 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
@@ -99,69 +97,73 @@ export function AppProviders({ children }: { children: ReactNode }) {
})
.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.
if (!cfg?.lock_connection_mode) {
// JWT expired — fall back to local so local tools still work.
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode('local');
pendingDetail = { locked: false };
// Show sign-in modal once per expiry cycle. If the user dismisses
// without signing in the flag stays set and we won't prompt again
// until they successfully sign in (which clears the flag).
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, 'true');
setPendingSignIn(true);
}
}
// Locked deployments stay in their configured mode — user can sign in
// via Settings when they're ready.
}
})
.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 {
if (!cfg?.lock_connection_mode) {
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode('local');
pendingDetail = { locked: false };
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, 'true');
setPendingSignIn(true);
}
}
})
.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);
}
});
.finally(() => setAuthChecked(true));
}
} else if (isFirstLaunch && !setupComplete) {
// 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.
// Guard against re-running when setConnectionMode triggers this effect.
if (firstLaunchInitiated.current) return;
firstLaunchInitiated.current = true;
connectionModeService.switchToLocal()
.then(() => tauriBackendService.startBackend())
connectionModeService.getCurrentConfig()
.then(async (cfg) => {
if (cfg.lock_connection_mode && cfg.server_config?.url) {
// Locked provisioned deployment — do NOT switch to local (would clear server_config
// from the store). Show onboarding normally; the sign-in slide handles locked auth.
// Still start the local backend so local tools work while the user signs in.
await tauriBackendService.startBackend().catch(console.error);
setConnectionMode('selfhosted');
} else {
// Normal first launch — auto-enter local mode.
// The onboarding carousel + sign-in slide will be shown inside the main app.
await connectionModeService.switchToLocal();
await tauriBackendService.startBackend();
setConnectionMode('local');
}
})
.catch(console.error)
.finally(() => {
setConnectionMode('local');
setAuthChecked(true);
});
.finally(() => setAuthChecked(true));
}
}, [isFirstLaunch, setupComplete, connectionMode]);
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
if (connectionMode !== 'selfhosted') {
// Stop the monitor whenever we leave selfhosted mode so the dot resets.
selfHostedServerMonitor.stop();
return;
}
if (setupComplete && !isFirstLaunch) {
void tauriBackendService.initializeExternalBackend();
// Also start the self-hosted server monitor so the operation router and UI
// can detect when the remote server goes offline and fall back to local backend.
connectionModeService.getServerConfig().then(cfg => {
if (cfg?.url) {
selfHostedServerMonitor.start(cfg.url);
@@ -205,13 +207,16 @@ 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.
// Dispatch sign-in modal after authChecked so SignInModal's listener is registered.
// (Child effects run before parent effects, so this fires after SignInModal mounts.)
// detail.locked is always false here: setPendingSignIn(true) is only called inside
// `if (!cfg?.lock_connection_mode)` branches above, so locked deployments never set
// pendingSignIn and therefore never reach this dispatch.
useEffect(() => {
if (!authChecked || !pendingSignIn) return;
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: pendingSignIn }));
setPendingSignIn(null);
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }));
setPendingSignIn(false);
}, [authChecked, pendingSignIn]);
useEffect(() => {
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { Modal, Stack, Group, Button, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close';
@@ -24,20 +24,13 @@ 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);
// If the user dismissed the sign-in slide without authenticating, fall back to local mode
// so the app is usable without a server connection.
connectionModeService.switchToLocal().catch(console.error);
};
// X on slide 0 advances to sign-in slide rather than dismissing entirely
@@ -63,7 +56,7 @@ export function DesktopOnboardingModal() {
const welcomeSlide = useMemo(() => WelcomeSlide(), []);
const totalSteps = 2;
if (!visible || isLocked === null || isLocked) return null;
if (!visible) return null;
return (
<Modal
@@ -12,6 +12,7 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { listen } from '@tauri-apps/api/event';
import '@app/routes/authShared/auth.css';
import { DisabledButtonWithTooltip } from '@app/components/shared/DisabledButtonWithTooltip';
enum SetupStep {
SaaSLogin,
@@ -28,6 +29,7 @@ interface SetupWizardProps {
onClose?: () => void;
}
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout = false, onClose }) => {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
@@ -313,10 +315,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
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');
const serverUrl = currentConfig.server_config?.url;
if (!serverUrl) return;
setLockConnectionMode(true);
@@ -433,6 +432,26 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
>
{t('setup.selfhosted.unreachable.retry', 'Retry')}
</Button>
{lockConnectionMode ? (
<DisabledButtonWithTooltip
tooltip={t('setup.selfhosted.changeServerLocked', 'Your organisation has restricted this app to a specific server')}
>
{t('setup.selfhosted.unreachable.changeServer', 'Connect to a different server')}
</DisabledButtonWithTooltip>
) : (
<Button
variant="light"
color="blue"
fullWidth
loading={loading}
onClick={() => {
setLockedServerUnreachable(false);
setActiveStep(SetupStep.ServerSelection);
}}
>
{t('setup.selfhosted.unreachable.changeServer', 'Connect to a different server')}
</Button>
)}
<Button
variant="subtle"
color="white"
@@ -458,18 +477,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
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>
)}
<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>
</>
)}
@@ -0,0 +1,38 @@
.locked-button {
width: 100%;
text-align: center;
cursor: not-allowed;
user-select: none;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-md);
background-color: var(--mantine-color-blue-light);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
}
.locked-button-tooltip {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: calc(100% + 8px);
color: white;
white-space: nowrap;
pointer-events: none;
z-index: var(--mantine-z-index-popover);
box-shadow: var(--mantine-shadow-lg);
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-xs);
background-color: var(--mantine-color-dark-7);
padding: 6px var(--mantine-spacing-xs);
}
.locked-button-tooltip-arrow {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid var(--mantine-color-dark-7);
border-left-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
}
@@ -0,0 +1,36 @@
import React from 'react';
import '@app/components/shared/DisabledButtonWithTooltip.css';
interface DisabledButtonWithTooltipProps {
/** Tooltip text shown on hover */
tooltip: string;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* A visually disabled button that still responds to hover (showing a tooltip).
* Mantine's disabled prop prevents pointer events entirely, so this is a plain
* div styled to match a disabled button with a custom hover tooltip.
*/
export function DisabledButtonWithTooltip({ tooltip, children, className, style }: DisabledButtonWithTooltipProps) {
const [hovered, setHovered] = React.useState(false);
return (
<div
className="relative w-full"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className={`locked-button${className ? ` ${className}` : ''}`} style={style}>
{children}
</div>
{hovered && (
<div className="locked-button-tooltip">
{tooltip}
<div className="locked-button-tooltip-arrow" />
</div>
)}
</div>
);
}
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
export type ConnectionMode = 'saas' | 'selfhosted' | 'local';
@@ -37,6 +38,7 @@ export interface ConnectionTestResult {
}
export const LOCAL_MODE_STORAGE_KEY = 'stirling-local-mode';
export const JWT_EXPIRED_PROMPTED_KEY = 'stirling-jwt-expired-prompted';
export class ConnectionModeService {
private static instance: ConnectionModeService;
@@ -87,8 +89,11 @@ export class ConnectionModeService {
const localFlag = localStorage.getItem(LOCAL_MODE_STORAGE_KEY);
if (config.mode === 'saas' && localFlag === 'true') {
// User previously chose local-only mode.
if (localFlag === 'true') {
// User previously chose local-only mode (signed out or explicitly went offline).
// Applies to both 'saas' and 'selfhosted' store modes — the Rust guard on locked
// deployments can't change 'selfhosted' to 'saas' in the store, so we check the
// flag regardless of what the store says.
config.mode = 'local';
} else if (
config.mode === 'saas' &&
@@ -122,8 +127,9 @@ export class ConnectionModeService {
throw new Error('Connection mode is locked by provisioning');
}
// Clear local-only flag if switching to a real account
// Clear local-only flag and expiry-prompted flag when signing in
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
console.log('Switching to SaaS mode');
@@ -153,18 +159,17 @@ export class ConnectionModeService {
// 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 };
// For locked deployments, preserve server_config so the sign-in form can still
// show the correct server URL if the user wants to sign in later.
const isLocked = this.currentConfig?.lock_connection_mode ?? false;
const preservedServerConfig = isLocked ? (this.currentConfig?.server_config ?? null) : null;
this.currentConfig = { mode: 'local', server_config: preservedServerConfig, lock_connection_mode: isLocked };
// Clear endpoint availability cache when mode changes
endpointAvailabilityService.clearCache();
@@ -173,8 +178,9 @@ export class ConnectionModeService {
}
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
// Clear local-only flag if switching to a real account
// Clear local-only flag and expiry-prompted flag when signing in
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
console.log('Switching to self-hosted mode:', serverConfig);
@@ -189,6 +195,10 @@ export class ConnectionModeService {
endpointAvailabilityService.clearCache();
console.log('Cleared endpoint availability cache due to connection mode change');
// Single authoritative calling point for health monitoring — every path that
// switches to self-hosted mode funnels through here.
selfHostedServerMonitor.start(serverConfig.url);
this.notifyListeners();
console.log('Switched to self-hosted mode successfully');
@@ -441,7 +441,7 @@ const licenseService = {
*/
async getLicenseInfo(): Promise<LicenseInfo> {
try {
const response = await apiClient.get('/api/v1/admin/license-info');
const response = await apiClient.get('/api/v1/admin/license-info', { suppressErrorToast: true });
return response.data;
} catch (error) {
console.error('Error fetching license info:', error);