diff --git a/frontend/config/.env.desktop.example b/frontend/config/.env.desktop.example index 2e58bebec..a83666a4e 100644 --- a/frontend/config/.env.desktop.example +++ b/frontend/config/.env.desktop.example @@ -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= diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index c35791ca6..cf99c8aee 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src-tauri/src/commands/connection.rs b/frontend/src-tauri/src/commands/connection.rs index 8b8c68a81..157f25ce5 100644 --- a/frontend/src-tauri/src/commands/connection.rs +++ b/frontend/src-tauri/src/commands/connection.rs @@ -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))?, diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index 974d25a5f..92f2e6391 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -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(() => { diff --git a/frontend/src/desktop/components/DesktopOnboardingModal.tsx b/frontend/src/desktop/components/DesktopOnboardingModal.tsx index 6fa13017d..1526557eb 100644 --- a/frontend/src/desktop/components/DesktopOnboardingModal.tsx +++ b/frontend/src/desktop/components/DesktopOnboardingModal.tsx @@ -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(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 ( void; } + export const SetupWizard: React.FC = ({ onComplete, noLayout = false, onClose }) => { const { t } = useTranslation(); const [activeStep, setActiveStep] = useState(SetupStep.SaaSLogin); @@ -313,10 +315,7 @@ export const SetupWizard: React.FC = ({ 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 = ({ onComplete, noLayout = > {t('setup.selfhosted.unreachable.retry', 'Retry')} + {lockConnectionMode ? ( + + {t('setup.selfhosted.unreachable.changeServer', 'Connect to a different server')} + + ) : ( + + )} - - )} +
+ +
)} diff --git a/frontend/src/desktop/components/shared/DisabledButtonWithTooltip.css b/frontend/src/desktop/components/shared/DisabledButtonWithTooltip.css new file mode 100644 index 000000000..4bea11778 --- /dev/null +++ b/frontend/src/desktop/components/shared/DisabledButtonWithTooltip.css @@ -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; +} diff --git a/frontend/src/desktop/components/shared/DisabledButtonWithTooltip.tsx b/frontend/src/desktop/components/shared/DisabledButtonWithTooltip.tsx new file mode 100644 index 000000000..2262c9d07 --- /dev/null +++ b/frontend/src/desktop/components/shared/DisabledButtonWithTooltip.tsx @@ -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 ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > +
+ {children} +
+ {hovered && ( +
+ {tooltip} +
+
+ )} +
+ ); +} diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index 09fd09395..bcf487d93 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -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 { - // 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'); diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index 9767d5820..4a4da273f 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -441,7 +441,7 @@ const licenseService = { */ async getLicenseInfo(): Promise { 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);