From 2e2b55e87d1535ba8fe571a714866bd759d9ef82 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:36:48 +0000 Subject: [PATCH] Desktop/remove hard requirement auth wall on desktop (#5956) Co-authored-by: Claude Sonnet 4.6 --- frontend/config/.env.desktop.example | 4 + .../public/locales/en-GB/translation.toml | 40 +++- frontend/src-tauri/src/commands/backend.rs | 3 + .../src-tauri/src/state/connection_state.rs | 1 + .../orchestrator/onboardingConfig.ts | 5 +- .../src/core/components/tools/ToolPicker.tsx | 2 + .../tools/shared/OperationButton.tsx | 43 +++- .../tools/shared/createToolFlow.tsx | 54 ++++- .../tools/toolPicker/ToolButton.tsx | 31 ++- .../toolPicker/ToolPickerFooterExtensions.tsx | 7 + .../src/core/contexts/ToolActionsContext.tsx | 16 ++ .../src/core/contexts/ToolWorkflowContext.tsx | 22 ++ .../hooks/tools/shared/toolOperationTypes.ts | 10 + .../core/hooks/tools/shared/useBaseTool.ts | 1 - frontend/src/core/services/accountService.ts | 2 +- .../core/services/signatureStorageService.ts | 1 + frontend/src/core/styles/zIndex.ts | 3 + frontend/src/core/tools/AddAttachments.tsx | 3 +- frontend/src/core/tools/AddPageNumbers.tsx | 3 +- frontend/src/core/tools/AddPassword.tsx | 3 +- frontend/src/core/tools/AddStamp.tsx | 3 +- frontend/src/core/tools/AddWatermark.tsx | 3 +- frontend/src/core/tools/AdjustContrast.tsx | 2 +- frontend/src/core/tools/AdjustPageScale.tsx | 3 +- frontend/src/core/tools/AutoRename.tsx | 3 +- frontend/src/core/tools/BookletImposition.tsx | 3 +- frontend/src/core/tools/CertSign.tsx | 3 +- frontend/src/core/tools/ChangeMetadata.tsx | 3 +- frontend/src/core/tools/ChangePermissions.tsx | 3 +- frontend/src/core/tools/Compress.tsx | 3 +- frontend/src/core/tools/Convert.tsx | 3 +- frontend/src/core/tools/Crop.tsx | 3 +- frontend/src/core/tools/ExtractImages.tsx | 3 +- frontend/src/core/tools/ExtractPages.tsx | 3 +- frontend/src/core/tools/Flatten.tsx | 3 +- frontend/src/core/tools/GetPdfInfo.tsx | 7 +- frontend/src/core/tools/Merge.tsx | 3 +- frontend/src/core/tools/OCR.tsx | 5 +- frontend/src/core/tools/OverlayPdfs.tsx | 3 +- frontend/src/core/tools/PageLayout.tsx | 3 +- frontend/src/core/tools/RemoveAnnotations.tsx | 2 +- frontend/src/core/tools/RemoveBlanks.tsx | 3 +- .../src/core/tools/RemoveCertificateSign.tsx | 3 +- frontend/src/core/tools/RemoveImage.tsx | 3 +- frontend/src/core/tools/RemovePages.tsx | 3 +- frontend/src/core/tools/RemovePassword.tsx | 3 +- frontend/src/core/tools/ReorganizePages.tsx | 3 +- frontend/src/core/tools/Repair.tsx | 3 +- frontend/src/core/tools/ReplaceColor.tsx | 3 +- frontend/src/core/tools/Rotate.tsx | 3 +- frontend/src/core/tools/Sanitize.tsx | 3 +- frontend/src/core/tools/ScannerImageSplit.tsx | 3 +- frontend/src/core/tools/SingleLargePage.tsx | 3 +- frontend/src/core/tools/Split.tsx | 3 +- frontend/src/core/tools/UnlockPdfForms.tsx | 3 +- frontend/src/core/tools/ValidateSignature.tsx | 7 +- .../src/desktop/components/AppProviders.tsx | 211 +++++++++++------ .../desktop/components/ConnectionSettings.tsx | 60 +++-- .../components/DesktopOnboardingModal.tsx | 176 +++++++++++++++ .../SetupWizard/SaaSLoginScreen.tsx | 19 +- .../SetupWizard/ServerSelection.tsx | 7 +- .../desktop/components/SetupWizard/index.tsx | 213 +++++++++++++----- .../src/desktop/components/SignInModal.tsx | 47 ++++ .../rightRail/RightRailFooterExtensions.tsx | 99 +++++++- .../shared/SelfHostedOfflineBanner.tsx | 3 +- .../shared/config/configNavSections.tsx | 60 ++--- .../shared/modals/CreditModalBootstrap.tsx | 4 +- .../tools/toolPicker/ToolButton.tsx | 51 +++++ .../toolPicker/ToolPickerFooterExtensions.tsx | 54 +++++ .../src/desktop/constants/signInEvents.ts | 5 + .../src/desktop/contexts/SaaSTeamContext.tsx | 8 +- .../src/desktop/extensions/accountLogout.ts | 12 +- .../src/desktop/hooks/useBackendHealth.ts | 1 + .../src/desktop/hooks/useEndpointConfig.ts | 10 +- frontend/src/desktop/routes/Landing.tsx | 12 + frontend/src/desktop/routes/Login.tsx | 14 ++ .../src/desktop/routes/login/LoginHeader.tsx | 41 ++++ .../src/desktop/services/apiClientSetup.ts | 18 +- frontend/src/desktop/services/authService.ts | 35 ++- .../desktop/services/connectionModeService.ts | 64 +++++- .../src/desktop/services/httpErrorHandler.ts | 20 ++ .../src/desktop/services/operationRouter.ts | 107 ++++++--- .../services/selfHostedServerMonitor.ts | 3 +- .../desktop/services/tauriBackendService.ts | 89 ++++++++ .../proprietary/routes/authShared/auth.css | 24 +- .../src/proprietary/styles/auth-theme.css | 22 ++ frontend/vite-env.d.ts | 2 + 87 files changed, 1522 insertions(+), 339 deletions(-) create mode 100644 frontend/src/core/components/tools/toolPicker/ToolPickerFooterExtensions.tsx create mode 100644 frontend/src/core/contexts/ToolActionsContext.tsx create mode 100644 frontend/src/desktop/components/DesktopOnboardingModal.tsx create mode 100644 frontend/src/desktop/components/SignInModal.tsx create mode 100644 frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx create mode 100644 frontend/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx create mode 100644 frontend/src/desktop/constants/signInEvents.ts create mode 100644 frontend/src/desktop/routes/Landing.tsx create mode 100644 frontend/src/desktop/routes/Login.tsx create mode 100644 frontend/src/desktop/routes/login/LoginHeader.tsx create mode 100644 frontend/src/desktop/services/httpErrorHandler.ts diff --git a/frontend/config/.env.desktop.example b/frontend/config/.env.desktop.example index d7975153b1..2e58bebec8 100644 --- a/frontend/config/.env.desktop.example +++ b/frontend/config/.env.desktop.example @@ -7,3 +7,7 @@ VITE_DESKTOP_BACKEND_URL= # Desktop auth integration VITE_SAAS_SERVER_URL=https://auth.stirling.com 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 diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 3ab03cba75..0f29d5cef4 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -2682,6 +2682,14 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" +[connectionMode.status] +localOffline = "Offline mode running" +localOnline = "Offline mode running" +saas = "Connected to Stirling Cloud" +selfhostedChecking = "Connected to self-hosted server (checking...)" +selfhostedOffline = "Self-hosted server unreachable" +selfhostedOnline = "Connected to self-hosted server" + [convert] autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" @@ -5857,12 +5865,15 @@ systemSettings = "System Settings" title = "Configuration" [settings.connection] +localDescription = "You are using the local backend without an account. Some tools requiring cloud processing or a self-hosted server are unavailable." logout = "Log Out" server = "Server" +signIn = "Sign In" title = "Connection Mode" user = "Logged in as" [settings.connection.mode] +local = "Local Only" saas = "Stirling Cloud" selfhosted = "Self-Hosted" @@ -6060,6 +6071,18 @@ title = "Workspace" [settings.team] title = "Team" +[localMode] +toolUnavailable = "This tool requires an account. Sign in to Stirling Cloud or connect to a self-hosted server to use it." + +[localMode.banner] +message = "Sign in to unlock all tools." +signIn = "Sign In" +title = "Running locally" + +[localMode.toolPicker] +message = "Sign in to unlock all tools." +signIn = "Sign In" + [setup] description = "Get started by choosing how you want to use Stirling PDF" welcome = "Welcome to Stirling PDF" @@ -6067,6 +6090,7 @@ welcome = "Welcome to Stirling PDF" [setup.login] connectingTo = "Connecting to:" hideInstructions = "Hide instructions" +skipSignIn = "Continue without signing in" instructions = "To enable login on your Stirling PDF server:" instructionsEnvVar = "Set the environment variable:" instructionsOrYml = "Or in settings.yml:" @@ -6114,8 +6138,15 @@ title = "Sign in to Stirling" [setup.selfhosted] link = "or connect to a self-hosted account" subtitle = "Enter your server credentials" +switchToLocal = "Use local tools instead" title = "Sign in to Server" +[setup.selfhosted.unreachable] +continueOffline = "Use local tools instead" +message = "Could not reach {{url}}. Check that the server is running and accessible." +retry = "Retry" +title = "Cannot connect to server" + [setup.server] subtitle = "Enter your self-hosted server URL" testing = "Testing connection..." @@ -6123,7 +6154,8 @@ title = "Connect to Server" useLast = "Last used server: {{serverUrl}}" [setup.server.error] -configFetch = "Failed to fetch server configuration. Please check the URL and try again." +configFetch = "Failed to fetch server configuration (status {{status}})" +configFetchError = "Failed to fetch server configuration: {{error}}" emptyUrl = "Please enter a server URL" invalidUrl = "Invalid URL format. Please enter a valid URL like https://your-server.com" testFailed = "Connection test failed" @@ -6695,6 +6727,12 @@ removal = "Removal" signing = "Signing" verification = "Verification" +[tool] +endpointUnavailable = "This tool is unavailable on your server." +endpointUnavailableClickable = "Not available in this mode. Click to sign in." +invalidParams = "Fill in the required settings." +noFiles = "Add a file to get started." + [tools] noSearchResults = "No tools found" noTools = "No tools available" diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index 19c1171747..8d5a9764f1 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -398,6 +398,9 @@ pub async fn start_backend( ConnectionMode::SelfHosted => { add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string()); } + ConnectionMode::Local => { + add_log("πŸ’» Running in Local-only mode - starting local backend".to_string()); + } } // Check if backend is already running or starting diff --git a/frontend/src-tauri/src/state/connection_state.rs b/frontend/src-tauri/src/state/connection_state.rs index 1e4ca9b176..e60f3e3696 100644 --- a/frontend/src-tauri/src/state/connection_state.rs +++ b/frontend/src-tauri/src/state/connection_state.rs @@ -6,6 +6,7 @@ use std::sync::Mutex; pub enum ConnectionMode { SaaS, SelfHosted, + Local, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 51576bc602..9528694c3e 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -78,7 +78,8 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ id: 'welcome', type: 'modal-slide', slideId: 'welcome', - condition: () => true, + // Desktop has its own onboarding modal (DesktopOnboardingModal) + condition: (ctx) => !ctx.isDesktopApp, }, { id: 'admin-overview', @@ -107,7 +108,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ id: 'tour-overview', type: 'modal-slide', slideId: 'tour-overview', - condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin', + condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin' && !ctx.isDesktopApp, }, { id: 'server-license', diff --git a/frontend/src/core/components/tools/ToolPicker.tsx b/frontend/src/core/components/tools/ToolPicker.tsx index b9da16689e..9642991989 100644 --- a/frontend/src/core/components/tools/ToolPicker.tsx +++ b/frontend/src/core/components/tools/ToolPicker.tsx @@ -12,6 +12,7 @@ import ToolButton from "@app/components/tools/toolPicker/ToolButton"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { ToolId } from "@app/types/toolId"; import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; +import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/ToolPickerFooterExtensions"; interface ToolPickerProps { selectedToolKey: string | null; @@ -150,6 +151,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa )} + ); }; diff --git a/frontend/src/core/components/tools/shared/OperationButton.tsx b/frontend/src/core/components/tools/shared/OperationButton.tsx index ef0a0814e0..0e4399e488 100644 --- a/frontend/src/core/components/tools/shared/OperationButton.tsx +++ b/frontend/src/core/components/tools/shared/OperationButton.tsx @@ -3,11 +3,14 @@ import { useTranslation } from 'react-i18next'; import { Tooltip } from '@app/components/shared/Tooltip'; import { useBackendHealth } from '@app/hooks/useBackendHealth'; import { CloudBadge } from '@app/components/shared/CloudBadge'; +import type { ExecuteDisabledReason } from '@app/hooks/tools/shared/toolOperationTypes'; +import { useToolActions } from '@app/contexts/ToolActionsContext'; export interface OperationButtonProps { onClick?: () => void; isLoading?: boolean; disabled?: boolean; + disabledReason?: ExecuteDisabledReason; loadingText?: string; submitText?: string; variant?: 'filled' | 'outline' | 'subtle'; @@ -24,6 +27,7 @@ const OperationButton = ({ onClick, isLoading = false, disabled = false, + disabledReason, loadingText, submitText, variant = 'filled', @@ -37,20 +41,32 @@ const OperationButton = ({ }: OperationButtonProps) => { const { t } = useTranslation(); const { isOnline, message: backendMessage } = useBackendHealth(); + const { onEndpointUnavailableClick } = useToolActions(); const blockedByBackend = !isOnline; - const combinedDisabled = disabled || blockedByBackend; + + const effectiveDisabled = disabled || disabledReason !== null && disabledReason !== undefined; + const combinedDisabled = effectiveDisabled || blockedByBackend; + + const reasonTooltip: Record, string> = { + endpointUnavailable: onEndpointUnavailableClick + ? t('tool.endpointUnavailableClickable', "Not available in this mode. Click to sign in.") + : t('tool.endpointUnavailable', 'This tool is unavailable on your server.'), + noFiles: t('tool.noFiles', 'Add a file to get started.'), + invalidParams: t('tool.invalidParams', 'Fill in the required settings.'), + }; + const tooltipLabel = blockedByBackend ? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...')) - : null; + : (disabledReason ? (reasonTooltip[disabledReason] ?? null) : null); const button = ( ); - const star = hasStars && !isUnavailable ? ( + const star = hasStars && !visuallyUnavailable ? ( toggleFavorite(id as ToolId)} diff --git a/frontend/src/core/components/tools/toolPicker/ToolPickerFooterExtensions.tsx b/frontend/src/core/components/tools/toolPicker/ToolPickerFooterExtensions.tsx new file mode 100644 index 0000000000..6e86a42529 --- /dev/null +++ b/frontend/src/core/components/tools/toolPicker/ToolPickerFooterExtensions.tsx @@ -0,0 +1,7 @@ +/** + * Stub β€” returns null in core/web builds. + * Desktop build shadows this with a sign-in prompt for local mode. + */ +export function ToolPickerFooterExtensions() { + return null; +} diff --git a/frontend/src/core/contexts/ToolActionsContext.tsx b/frontend/src/core/contexts/ToolActionsContext.tsx new file mode 100644 index 0000000000..6d87543e0c --- /dev/null +++ b/frontend/src/core/contexts/ToolActionsContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; + +interface ToolActionsContextValue { + /** + * Called when the user clicks the disabled execute button while the reason + * is 'endpointUnavailable'. Desktop provides a sign-in modal dispatch; + * web builds leave this undefined (button stays disabled with tooltip only). + */ + onEndpointUnavailableClick?: () => void; +} + +export const ToolActionsContext = createContext({}); + +export function useToolActions(): ToolActionsContextValue { + return useContext(ToolActionsContext); +} diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index a87814a8cf..83d4fe0326 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -66,6 +66,10 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { // Workflow Actions (compound actions) handleToolSelect: (toolId: ToolId) => void; + /** Like handleToolSelect but bypasses the availability guard β€” use when you want to + * navigate to a tool's UI even if it's marked unavailable (e.g. to show a disabled + * execute button with a sign-in prompt rather than blocking navigation entirely). */ + handleToolSelectForced: (toolId: ToolId) => void; handleBackToTools: () => void; handleReaderToggle: () => void; @@ -321,6 +325,23 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setReaderMode(false); // Disable read mode when selecting tools }, [actions, getSelectedTool, navigationState.workbench, navigationState.hasUnsavedChanges, navigationState.selectedTool, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]); + const handleToolSelectForced = useCallback((toolId: ToolId) => { + const validToolId = isValidToolId(toolId) ? toolId : null; + actions.setSelectedTool(validToolId); + const tool = getSelectedTool(toolId); + const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench); + if (wasInCustomWorkbench) { + actions.setWorkbench(getDefaultWorkbench()); + } else if (tool && tool.workbench) { + actions.setWorkbench(tool.workbench); + } else { + actions.setWorkbench(getDefaultWorkbench()); + } + setSearchQuery(''); + setLeftPanelView('toolContent'); + setReaderMode(false); + }, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]); + const handleBackToTools = useCallback(() => { setLeftPanelView('toolPicker'); setReaderMode(false); @@ -378,6 +399,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // Workflow Actions handleToolSelect, + handleToolSelectForced, handleBackToTools, handleReaderToggle, diff --git a/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts index 7269c0f456..455c18ee50 100644 --- a/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -11,6 +11,16 @@ export enum ToolType { custom, } +/** + * Reason the execute button is disabled. Resolved to a translated tooltip by OperationButton. + * null means the button is enabled. + */ +export type ExecuteDisabledReason = + | 'endpointUnavailable' + | 'noFiles' + | 'invalidParams' + | null; + /** * Result from custom processor with optional metadata about input consumption. */ diff --git a/frontend/src/core/hooks/tools/shared/useBaseTool.ts b/frontend/src/core/hooks/tools/shared/useBaseTool.ts index 1b8db3bd1e..f07689e0d1 100644 --- a/frontend/src/core/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/core/hooks/tools/shared/useBaseTool.ts @@ -152,7 +152,6 @@ export function useBaseTool { - const response = await apiClient.get('/api/v1/proprietary/ui-data/account'); + const response = await apiClient.get('/api/v1/proprietary/ui-data/account', { suppressErrorToast: true }); return response.data; }, diff --git a/frontend/src/core/services/signatureStorageService.ts b/frontend/src/core/services/signatureStorageService.ts index afe99afdc1..096c3af738 100644 --- a/frontend/src/core/services/signatureStorageService.ts +++ b/frontend/src/core/services/signatureStorageService.ts @@ -42,6 +42,7 @@ class SignatureStorageService { // Probe the proprietary signatures endpoint (requires authentication) await apiClient.get('/api/v1/proprietary/signatures', { timeout: 3000, + suppressErrorToast: true, }); // 200 = Backend available and accessible (authenticated) diff --git a/frontend/src/core/styles/zIndex.ts b/frontend/src/core/styles/zIndex.ts index 8d3af6ae73..11f2016554 100644 --- a/frontend/src/core/styles/zIndex.ts +++ b/frontend/src/core/styles/zIndex.ts @@ -22,6 +22,9 @@ export const Z_INDEX_DRAG_BADGE = 1001; // Modal that appears on top of config modal (e.g., restart confirmation, update modal) export const Z_INDEX_OVER_CONFIG_MODAL = 2000; +// Sign-in modal β€” must appear above all app UI including config and analytics modals +export const Z_INDEX_SIGN_IN_MODAL = 9000; + // Toast notifications and error displays - Always on top (higher than rainbow theme at 10000) export const Z_INDEX_TOAST = 10001; diff --git a/frontend/src/core/tools/AddAttachments.tsx b/frontend/src/core/tools/AddAttachments.tsx index 199ea1d0ee..036f0e7e36 100644 --- a/frontend/src/core/tools/AddAttachments.tsx +++ b/frontend/src/core/tools/AddAttachments.tsx @@ -90,7 +90,8 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = isVisible: !hasResults, loadingText: t('loading'), onClick: handleExecute, - disabled: !params.validateParameters() || !hasFiles || !endpointEnabled, + endpointEnabled: endpointEnabled, + paramsValid: params.validateParameters(), }, review: { isVisible: hasResults, diff --git a/frontend/src/core/tools/AddPageNumbers.tsx b/frontend/src/core/tools/AddPageNumbers.tsx index 3740c5f222..1f7f09c78c 100644 --- a/frontend/src/core/tools/AddPageNumbers.tsx +++ b/frontend/src/core/tools/AddPageNumbers.tsx @@ -106,7 +106,8 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = isVisible: !hasResults, loadingText: t('loading'), onClick: handleExecute, - disabled: !params.validateParameters() || !hasFiles || !endpointEnabled, + endpointEnabled: endpointEnabled, + paramsValid: params.validateParameters(), }, review: { isVisible: hasResults, diff --git a/frontend/src/core/tools/AddPassword.tsx b/frontend/src/core/tools/AddPassword.tsx index 0986422ab6..72657ae80a 100644 --- a/frontend/src/core/tools/AddPassword.tsx +++ b/frontend/src/core/tools/AddPassword.tsx @@ -106,7 +106,8 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isVisible: !hasResults, loadingText: t("loading"), onClick: handleAddPassword, - disabled: !addPasswordParams.validateParameters() || !hasFiles || !endpointEnabled, + endpointEnabled: endpointEnabled, + paramsValid: addPasswordParams.validateParameters(), }, review: { isVisible: hasResults, diff --git a/frontend/src/core/tools/AddStamp.tsx b/frontend/src/core/tools/AddStamp.tsx index b0ad8fab15..b55a9be677 100644 --- a/frontend/src/core/tools/AddStamp.tsx +++ b/frontend/src/core/tools/AddStamp.tsx @@ -167,7 +167,8 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isVisible: !hasResults, loadingText: t('loading'), onClick: handleExecute, - disabled: !params.validateParameters() || !hasFiles || !endpointEnabled, + endpointEnabled: endpointEnabled, + paramsValid: params.validateParameters(), }, review: { isVisible: hasResults, diff --git a/frontend/src/core/tools/AddWatermark.tsx b/frontend/src/core/tools/AddWatermark.tsx index c7c040f2b3..c0447e12d0 100644 --- a/frontend/src/core/tools/AddWatermark.tsx +++ b/frontend/src/core/tools/AddWatermark.tsx @@ -199,7 +199,8 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => isVisible: !hasResults, loadingText: t("loading"), onClick: handleAddWatermark, - disabled: !watermarkParams.validateParameters() || !hasFiles || !endpointEnabled, + endpointEnabled: endpointEnabled, + paramsValid: watermarkParams.validateParameters(), }, review: { isVisible: hasResults, diff --git a/frontend/src/core/tools/AdjustContrast.tsx b/frontend/src/core/tools/AdjustContrast.tsx index 7acd606fab..2ec772d6ca 100644 --- a/frontend/src/core/tools/AdjustContrast.tsx +++ b/frontend/src/core/tools/AdjustContrast.tsx @@ -103,7 +103,7 @@ const AdjustContrast = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t('loading'), onClick: base.handleExecute, - disabled: !base.hasFiles, + paramsValid: true, }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/AdjustPageScale.tsx b/frontend/src/core/tools/AdjustPageScale.tsx index 66b1ab2f74..52fadd8260 100644 --- a/frontend/src/core/tools/AdjustPageScale.tsx +++ b/frontend/src/core/tools/AdjustPageScale.tsx @@ -43,7 +43,8 @@ const AdjustPageScale = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/AutoRename.tsx b/frontend/src/core/tools/AutoRename.tsx index d665cfa68d..fba80fba30 100644 --- a/frontend/src/core/tools/AutoRename.tsx +++ b/frontend/src/core/tools/AutoRename.tsx @@ -36,7 +36,8 @@ return createToolFlow({ isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/BookletImposition.tsx b/frontend/src/core/tools/BookletImposition.tsx index 6e89cdeeef..86a4ee344c 100644 --- a/frontend/src/core/tools/BookletImposition.tsx +++ b/frontend/src/core/tools/BookletImposition.tsx @@ -44,7 +44,8 @@ const BookletImposition = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/CertSign.tsx b/frontend/src/core/tools/CertSign.tsx index 10d6b491ec..a272d7c63c 100644 --- a/frontend/src/core/tools/CertSign.tsx +++ b/frontend/src/core/tools/CertSign.tsx @@ -113,7 +113,8 @@ const CertSign = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ChangeMetadata.tsx b/frontend/src/core/tools/ChangeMetadata.tsx index 988667a974..e44a298526 100644 --- a/frontend/src/core/tools/ChangeMetadata.tsx +++ b/frontend/src/core/tools/ChangeMetadata.tsx @@ -141,7 +141,8 @@ const ChangeMetadata = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ChangePermissions.tsx b/frontend/src/core/tools/ChangePermissions.tsx index 7035a52e3b..baa38cce75 100644 --- a/frontend/src/core/tools/ChangePermissions.tsx +++ b/frontend/src/core/tools/ChangePermissions.tsx @@ -43,7 +43,8 @@ const ChangePermissions = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/Compress.tsx b/frontend/src/core/tools/Compress.tsx index 9b48153281..ae5dd691fb 100644 --- a/frontend/src/core/tools/Compress.tsx +++ b/frontend/src/core/tools/Compress.tsx @@ -43,7 +43,8 @@ const Compress = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/Convert.tsx b/frontend/src/core/tools/Convert.tsx index 16782c9827..e74c46fef4 100644 --- a/frontend/src/core/tools/Convert.tsx +++ b/frontend/src/core/tools/Convert.tsx @@ -152,7 +152,8 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { loadingText: t("convert.converting", "Converting..."), onClick: handleConvert, isVisible: !hasResults, - disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled, + endpointEnabled: endpointEnabled, + paramsValid: convertParams.validateParameters(), testId: "convert-button", }, review: { diff --git a/frontend/src/core/tools/Crop.tsx b/frontend/src/core/tools/Crop.tsx index 57c8b30258..93ebb1c5c1 100644 --- a/frontend/src/core/tools/Crop.tsx +++ b/frontend/src/core/tools/Crop.tsx @@ -44,7 +44,8 @@ const Crop = (props: BaseToolProps) => { loadingText: t("loading"), onClick: base.handleExecute, isVisible: !base.hasResults, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ExtractImages.tsx b/frontend/src/core/tools/ExtractImages.tsx index 3aee009c99..5b6818db0a 100644 --- a/frontend/src/core/tools/ExtractImages.tsx +++ b/frontend/src/core/tools/ExtractImages.tsx @@ -40,7 +40,8 @@ const ExtractImages = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ExtractPages.tsx b/frontend/src/core/tools/ExtractPages.tsx index 402187aa88..ef0604bc4b 100644 --- a/frontend/src/core/tools/ExtractPages.tsx +++ b/frontend/src/core/tools/ExtractPages.tsx @@ -45,7 +45,8 @@ const ExtractPages = (props: BaseToolProps) => { loadingText: t("loading"), onClick: base.handleExecute, isVisible: !base.hasResults, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/Flatten.tsx b/frontend/src/core/tools/Flatten.tsx index 6f80686cd0..c6bfa35acc 100644 --- a/frontend/src/core/tools/Flatten.tsx +++ b/frontend/src/core/tools/Flatten.tsx @@ -43,7 +43,8 @@ const Flatten = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/GetPdfInfo.tsx b/frontend/src/core/tools/GetPdfInfo.tsx index aa35fe16bf..c34da7c23d 100644 --- a/frontend/src/core/tools/GetPdfInfo.tsx +++ b/frontend/src/core/tools/GetPdfInfo.tsx @@ -163,11 +163,8 @@ const GetPdfInfo = (props: BaseToolProps) => { text: t('getPdfInfo.submit', 'Generate'), loadingText: t('loading', 'Loading...'), onClick: base.handleExecute, - disabled: - !base.params.validateParameters() || - !base.hasFiles || - base.operation.isLoading || - !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), isVisible: true, }, review: { diff --git a/frontend/src/core/tools/Merge.tsx b/frontend/src/core/tools/Merge.tsx index 05d358f700..44dd5c89c1 100644 --- a/frontend/src/core/tools/Merge.tsx +++ b/frontend/src/core/tools/Merge.tsx @@ -134,7 +134,8 @@ const Merge = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/OCR.tsx b/frontend/src/core/tools/OCR.tsx index d9ad71c7a1..07f05c812b 100644 --- a/frontend/src/core/tools/OCR.tsx +++ b/frontend/src/core/tools/OCR.tsx @@ -128,8 +128,9 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { text: t("ocr.operation.submit", "Process OCR and Review"), loadingText: t("loading"), onClick: handleOCR, - isVisible: hasValidSettings && !hasResults, - disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled, + isVisible: !hasResults, + endpointEnabled: endpointEnabled, + paramsValid: hasValidSettings, }, review: { isVisible: hasResults, diff --git a/frontend/src/core/tools/OverlayPdfs.tsx b/frontend/src/core/tools/OverlayPdfs.tsx index adae6fffbb..cacf53ba3d 100644 --- a/frontend/src/core/tools/OverlayPdfs.tsx +++ b/frontend/src/core/tools/OverlayPdfs.tsx @@ -43,7 +43,8 @@ const OverlayPdfs = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t('loading'), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/PageLayout.tsx b/frontend/src/core/tools/PageLayout.tsx index 1b8608dab1..bc6e71fb1f 100644 --- a/frontend/src/core/tools/PageLayout.tsx +++ b/frontend/src/core/tools/PageLayout.tsx @@ -40,7 +40,8 @@ const PageLayout = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t('loading'), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/RemoveAnnotations.tsx b/frontend/src/core/tools/RemoveAnnotations.tsx index e2bf6c3f45..2a84fcbbf3 100644 --- a/frontend/src/core/tools/RemoveAnnotations.tsx +++ b/frontend/src/core/tools/RemoveAnnotations.tsx @@ -37,7 +37,7 @@ const RemoveAnnotations = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading", "Processing..."), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/RemoveBlanks.tsx b/frontend/src/core/tools/RemoveBlanks.tsx index f47a02f936..bc2511b37b 100644 --- a/frontend/src/core/tools/RemoveBlanks.tsx +++ b/frontend/src/core/tools/RemoveBlanks.tsx @@ -51,7 +51,8 @@ const RemoveBlanks = (props: BaseToolProps) => { loadingText: t("loading"), onClick: base.handleExecute, isVisible: !base.hasResults, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/RemoveCertificateSign.tsx b/frontend/src/core/tools/RemoveCertificateSign.tsx index c182d920ee..946aa76923 100644 --- a/frontend/src/core/tools/RemoveCertificateSign.tsx +++ b/frontend/src/core/tools/RemoveCertificateSign.tsx @@ -26,7 +26,8 @@ const RemoveCertificateSign = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/RemoveImage.tsx b/frontend/src/core/tools/RemoveImage.tsx index 27b07a9a34..57f88b4605 100644 --- a/frontend/src/core/tools/RemoveImage.tsx +++ b/frontend/src/core/tools/RemoveImage.tsx @@ -26,7 +26,8 @@ const RemoveImage = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/RemovePages.tsx b/frontend/src/core/tools/RemovePages.tsx index 93b905f211..5e3d20822c 100644 --- a/frontend/src/core/tools/RemovePages.tsx +++ b/frontend/src/core/tools/RemovePages.tsx @@ -46,7 +46,8 @@ const RemovePages = (props: BaseToolProps) => { loadingText: t("loading"), onClick: base.handleExecute, isVisible: !base.hasResults, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/RemovePassword.tsx b/frontend/src/core/tools/RemovePassword.tsx index a39fc4efd9..af72201e3d 100644 --- a/frontend/src/core/tools/RemovePassword.tsx +++ b/frontend/src/core/tools/RemovePassword.tsx @@ -43,7 +43,8 @@ const RemovePassword = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ReorganizePages.tsx b/frontend/src/core/tools/ReorganizePages.tsx index 9ce1ec2dad..d70f7a2667 100644 --- a/frontend/src/core/tools/ReorganizePages.tsx +++ b/frontend/src/core/tools/ReorganizePages.tsx @@ -82,7 +82,8 @@ const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps) isVisible: !hasResults, loadingText: t('loading'), onClick: handleExecute, - disabled: !params.validateParameters() || !hasFiles || !endpointEnabled, + endpointEnabled: endpointEnabled, + paramsValid: params.validateParameters(), }, review: { isVisible: hasResults, diff --git a/frontend/src/core/tools/Repair.tsx b/frontend/src/core/tools/Repair.tsx index 22e2fff9f2..f391e23810 100644 --- a/frontend/src/core/tools/Repair.tsx +++ b/frontend/src/core/tools/Repair.tsx @@ -26,7 +26,8 @@ const Repair = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ReplaceColor.tsx b/frontend/src/core/tools/ReplaceColor.tsx index a86512b09e..5fd1968ea1 100644 --- a/frontend/src/core/tools/ReplaceColor.tsx +++ b/frontend/src/core/tools/ReplaceColor.tsx @@ -43,7 +43,8 @@ const ReplaceColor = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/Rotate.tsx b/frontend/src/core/tools/Rotate.tsx index 78178055c3..ec45a00648 100644 --- a/frontend/src/core/tools/Rotate.tsx +++ b/frontend/src/core/tools/Rotate.tsx @@ -42,7 +42,8 @@ const Rotate = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/Sanitize.tsx b/frontend/src/core/tools/Sanitize.tsx index 121c8a9ab5..09c89e2c37 100644 --- a/frontend/src/core/tools/Sanitize.tsx +++ b/frontend/src/core/tools/Sanitize.tsx @@ -40,7 +40,8 @@ const Sanitize = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ScannerImageSplit.tsx b/frontend/src/core/tools/ScannerImageSplit.tsx index 1acf17d63b..74f2edbb43 100644 --- a/frontend/src/core/tools/ScannerImageSplit.tsx +++ b/frontend/src/core/tools/ScannerImageSplit.tsx @@ -43,7 +43,8 @@ const ScannerImageSplit = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/SingleLargePage.tsx b/frontend/src/core/tools/SingleLargePage.tsx index faa6e7932a..76da9afdde 100644 --- a/frontend/src/core/tools/SingleLargePage.tsx +++ b/frontend/src/core/tools/SingleLargePage.tsx @@ -26,7 +26,8 @@ const SingleLargePage = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/Split.tsx b/frontend/src/core/tools/Split.tsx index 1a23b146fe..6d6313dfd5 100644 --- a/frontend/src/core/tools/Split.tsx +++ b/frontend/src/core/tools/Split.tsx @@ -89,7 +89,8 @@ const Split = (props: BaseToolProps) => { loadingText: t("loading"), onClick: base.handleExecute, isVisible: !base.hasResults, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/UnlockPdfForms.tsx b/frontend/src/core/tools/UnlockPdfForms.tsx index e8ba292d5e..b1b562c72f 100644 --- a/frontend/src/core/tools/UnlockPdfForms.tsx +++ b/frontend/src/core/tools/UnlockPdfForms.tsx @@ -26,7 +26,8 @@ const UnlockPdfForms = (props: BaseToolProps) => { isVisible: !base.hasResults, loadingText: t("loading"), onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), }, review: { isVisible: base.hasResults, diff --git a/frontend/src/core/tools/ValidateSignature.tsx b/frontend/src/core/tools/ValidateSignature.tsx index 30fab68d99..52cd5592e0 100644 --- a/frontend/src/core/tools/ValidateSignature.tsx +++ b/frontend/src/core/tools/ValidateSignature.tsx @@ -142,11 +142,8 @@ const ValidateSignature = (props: BaseToolProps) => { text: t('validateSignature.submit', 'Validate Signatures'), loadingText: t('loading', 'Loading...'), onClick: base.handleExecute, - disabled: - !base.params.validateParameters() || - !base.hasFiles || - base.operation.isLoading || - !base.endpointEnabled, + endpointEnabled: base.endpointEnabled, + paramsValid: base.params.validateParameters(), isVisible: true, }, review: { diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index 30d84fd68d..974d25a5f9 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -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 ( - - { - window.location.reload(); - }} - /> - - ); - } - - // Show setup wizard when not authenticated (desktop login flow). - if (authChecked && !isAuthenticated) { - return ( - - { - window.location.reload(); - }} - /> - - ); - } - // Normal app flow return ( - - - - - - - - {children} - - - + window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)), + }}> + + + + + + + + {children} + {/* Desktop onboarding modal: welcome slide β†’ sign-in slide, shown once on first launch */} + + {/* Global sign-in modal, opened via stirling:open-sign-in event */} + + + + + ); } diff --git a/frontend/src/desktop/components/ConnectionSettings.tsx b/frontend/src/desktop/components/ConnectionSettings.tsx index 3fb972aa48..fa75cddcef 100644 --- a/frontend/src/desktop/components/ConnectionSettings.tsx +++ b/frontend/src/desktop/components/ConnectionSettings.tsx @@ -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 {t('common.loading', 'Loading...')}; } @@ -66,13 +70,27 @@ export const ConnectionSettings: React.FC = () => { {t('settings.connection.title', 'Connection Mode')} - + {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')} + {config.mode === 'local' && ( + + {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.' + )} + + )} + {(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && ( <>
@@ -99,9 +117,15 @@ export const ConnectionSettings: React.FC = () => { )} - + {config.mode === 'local' ? ( + + ) : ( + + )} diff --git a/frontend/src/desktop/components/DesktopOnboardingModal.tsx b/frontend/src/desktop/components/DesktopOnboardingModal.tsx new file mode 100644 index 0000000000..6fa13017d2 --- /dev/null +++ b/frontend/src/desktop/components/DesktopOnboardingModal.tsx @@ -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(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 ( + + + {/* Hero section β€” gradient changes per slide */} +
+ + + + +
+
+ {step === 0 ? ( + + ) : ( + + )} +
+
+
+ + {/* Body section */} +
+ {step === 0 ? ( + // Welcome slide + +
+ {welcomeSlide.title} +
+
+
+ {welcomeSlide.body} +
+ +
+ +
+ + + +
+
+ ) : ( + // Sign-in slide + + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index 644e5fa985..2dc749548c 100644 --- a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -15,6 +15,8 @@ interface SaaSLoginScreenProps { onOAuthSuccess: (userInfo: UserInfo) => Promise; onSelfHostedClick: () => void; onSwitchToSignup: () => void; + onSkipSignIn?: () => void; + onClose?: () => void; loading: boolean; error: string | null; } @@ -25,6 +27,8 @@ export const SaaSLoginScreen: React.FC = ({ onOAuthSuccess, onSelfHostedClick, onSwitchToSignup, + onSkipSignIn, + onClose, loading, error, }) => { @@ -57,7 +61,7 @@ export const SaaSLoginScreen: React.FC = ({ return ( <> - + @@ -110,6 +114,19 @@ export const SaaSLoginScreen: React.FC = ({
+ + {onSkipSignIn && ( +
+ +
+ )} ); }; diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx index d709315a31..aa065a1284 100644 --- a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -35,8 +35,6 @@ export const ServerSelection: React.FC = ({ 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 = ({ 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 = ({ 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, diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index 2ef02a95cb..392caad915 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -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 = ({ onComplete }) => { +export const SetupWizard: React.FC = ({ onComplete, noLayout = false, onClose }) => { const { t } = useTranslation(); const [activeStep, setActiveStep] = useState(SetupStep.SaaSLogin); const [serverConfig, setServerConfig] = useState({ url: STIRLING_SAAS_URL }); @@ -32,6 +37,8 @@ export const SetupWizard: React.FC = ({ 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 = ({ 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 = ({ 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 ( - + useEffect(() => { + void loadLockedConfig(); + }, [loadLockedConfig]); + + const wizardContent = ( + <> {/* Step Content */} {!lockConnectionMode && activeStep === SetupStep.SaaSLogin && ( = ({ onComplete }) => { onOAuthSuccess={handleSaaSLoginOAuth} onSelfHostedClick={handleSelfHostedClick} onSwitchToSignup={handleSwitchToSignup} + onSkipSignIn={handleLocalMode} + onClose={onClose} loading={loading} error={error} /> @@ -367,19 +409,68 @@ export const SetupWizard: React.FC = ({ onComplete }) => { /> )} - {activeStep === SetupStep.SelfHostedLogin && ( - + {lockConnectionMode && lockedServerChecking && ( +
+ +
+ )} + + {activeStep === SetupStep.SelfHostedLogin && lockedServerUnreachable && !lockedServerChecking && ( + + + + {t('setup.selfhosted.unreachable.message', 'Could not reach {{url}}. Check that the server is running and accessible.', { + url: serverConfig?.url, + })} + + + + + + )} + + {activeStep === SetupStep.SelfHostedLogin && !lockedServerUnreachable && !lockedServerChecking && ( + <> + + {lockConnectionMode && ( +
+ +
+ )} + )} {/* Back Button */} @@ -394,6 +485,16 @@ export const SetupWizard: React.FC = ({ onComplete }) => { )} + + ); + + if (noLayout) { + return
{wizardContent}
; + } + + return ( + + {wizardContent} ); }; diff --git a/frontend/src/desktop/components/SignInModal.tsx b/frontend/src/desktop/components/SignInModal.tsx new file mode 100644 index 0000000000..1ab44b7ec0 --- /dev/null +++ b/frontend/src/desktop/components/SignInModal.tsx @@ -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 ( + { if (!locked) setOpened(false); }} + size={520} + centered + withCloseButton={false} + closeOnClickOutside={!locked} + closeOnEscape={!locked} + padding={0} + radius="lg" + zIndex={Z_INDEX_SIGN_IN_MODAL} + > + setOpened(false)} + onComplete={() => { + setOpened(false); + // No reload needed β€” AppProviders remounts the SaaS provider tree via + // connectionModeService subscription when mode changes. + }} + /> + + ); +} diff --git a/frontend/src/desktop/components/rightRail/RightRailFooterExtensions.tsx b/frontend/src/desktop/components/rightRail/RightRailFooterExtensions.tsx index 58d8d9b98d..070debc0ee 100644 --- a/frontend/src/desktop/components/rightRail/RightRailFooterExtensions.tsx +++ b/frontend/src/desktop/components/rightRail/RightRailFooterExtensions.tsx @@ -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(null); + const [selfHostedState, setSelfHostedState] = useState( + () => 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 ( + + { + 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', + }} + /> + + ); +} + export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) { return ( - + ); } diff --git a/frontend/src/desktop/components/shared/SelfHostedOfflineBanner.tsx b/frontend/src/desktop/components/shared/SelfHostedOfflineBanner.tsx index 0ab5d04ec0..a622c7feda 100644 --- a/frontend/src/desktop/components/shared/SelfHostedOfflineBanner.tsx +++ b/frontend/src/desktop/components/shared/SelfHostedOfflineBanner.tsx @@ -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 diff --git a/frontend/src/desktop/components/shared/config/configNavSections.tsx b/frontend/src/desktop/components/shared/config/configNavSections.tsx index 148ed1961e..5933744f19 100644 --- a/frontend/src/desktop/components/shared/config/configNavSections.tsx +++ b/frontend/src/desktop/components/shared/config/configNavSections.tsx @@ -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(false); + const [connectionMode, setConnectionMode] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(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: , + }, + ], + }; + + // 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: , - }, - ], - }); + 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; diff --git a/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx b/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx index 2f3c456ae2..e83e1714a9 100644 --- a/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx +++ b/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx @@ -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); diff --git a/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx b/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx new file mode 100644 index 0000000000..b59df1edbb --- /dev/null +++ b/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx @@ -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; + +/** + * 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 = (props) => { + const { toolAvailability, handleToolSelectForced } = useToolWorkflow(); + const { config } = useAppConfig(); + const premiumEnabled = config?.premiumEnabled; + const [connectionMode, setConnectionMode] = useState(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 ; +}; + +export default ToolButton; diff --git a/frontend/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx b/frontend/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx new file mode 100644 index 0000000000..fa1418e90f --- /dev/null +++ b/frontend/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx @@ -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(null); + + useEffect(() => { + void connectionModeService.getCurrentMode().then(setConnectionMode); + const unsubscribe = connectionModeService.subscribeToModeChanges((config) => { + setConnectionMode(config.mode); + }); + return unsubscribe; + }, []); + + if (connectionMode !== 'local') return null; + + return ( + + + {t('localMode.toolPicker.message', 'Sign in to unlock all tools.')} + + + + ); +} diff --git a/frontend/src/desktop/constants/signInEvents.ts b/frontend/src/desktop/constants/signInEvents.ts new file mode 100644 index 0000000000..f377dda3f4 --- /dev/null +++ b/frontend/src/desktop/constants/signInEvents.ts @@ -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'; diff --git a/frontend/src/desktop/contexts/SaaSTeamContext.tsx b/frontend/src/desktop/contexts/SaaSTeamContext.tsx index fcef6899e1..893854e2fc 100644 --- a/frontend/src/desktop/contexts/SaaSTeamContext.tsx +++ b/frontend/src/desktop/contexts/SaaSTeamContext.tsx @@ -118,7 +118,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) { } try { - const response = await apiClient.get('/api/v1/team/my'); + const response = await apiClient.get('/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(`/api/v1/team/${teamId}/members`); + const response = await apiClient.get(`/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(`/api/v1/team/${teamId}/invitations`); + const response = await apiClient.get(`/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('/api/v1/team/invitations/pending'); + const response = await apiClient.get('/api/v1/team/invitations/pending', { suppressErrorToast: true }); console.log('[SaaSTeamContext] Received invitations response:', response.data); setReceivedInvitations(response.data); } catch (error) { diff --git a/frontend/src/desktop/extensions/accountLogout.ts b/frontend/src/desktop/extensions/accountLogout.ts index 39a65c01f4..c45fcc8898 100644 --- a/frontend/src/desktop/extensions/accountLogout.ts +++ b/frontend/src/desktop/extensions/accountLogout.ts @@ -1,5 +1,4 @@ import { connectionModeService } from '@app/services/connectionModeService'; -import { STIRLING_SAAS_URL } from '@app/constants/connection'; type SignOutFn = () => Promise; @@ -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); diff --git a/frontend/src/desktop/hooks/useBackendHealth.ts b/frontend/src/desktop/hooks/useBackendHealth.ts index 19ccefc271..f8269be355 100644 --- a/frontend/src/desktop/hooks/useBackendHealth.ts +++ b/frontend/src/desktop/hooks/useBackendHealth.ts @@ -30,6 +30,7 @@ export function useBackendHealth() { useEffect(() => { void connectionModeService.getCurrentMode().then(setConnectionMode); + return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode)); }, []); useEffect(() => { diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index 87216cea74..1b5a49fcaa 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -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); + 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) { diff --git a/frontend/src/desktop/routes/Landing.tsx b/frontend/src/desktop/routes/Landing.tsx new file mode 100644 index 0000000000..0fe981c485 --- /dev/null +++ b/frontend/src/desktop/routes/Landing.tsx @@ -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 ; +} diff --git a/frontend/src/desktop/routes/Login.tsx b/frontend/src/desktop/routes/Login.tsx new file mode 100644 index 0000000000..6a2e4dcf8f --- /dev/null +++ b/frontend/src/desktop/routes/Login.tsx @@ -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 ; +} diff --git a/frontend/src/desktop/routes/login/LoginHeader.tsx b/frontend/src/desktop/routes/login/LoginHeader.tsx new file mode 100644 index 0000000000..354b36a8b6 --- /dev/null +++ b/frontend/src/desktop/routes/login/LoginHeader.tsx @@ -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 ( +
+
+
+ Stirling PDF + {title &&

{title}

} +
+ {onClose && ( + + + + )} +
+ {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index 1b44a98ed4..13a5324851 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -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 diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 5489f169b9..b7751ce53a 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -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 { 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(() => {}); + } } } diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index e0a32383eb..09fd093957 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -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 { try { const config = await invoke('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 { + 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 { + // 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', { diff --git a/frontend/src/desktop/services/httpErrorHandler.ts b/frontend/src/desktop/services/httpErrorHandler.ts new file mode 100644 index 0000000000..12bde3dacf --- /dev/null +++ b/frontend/src/desktop/services/httpErrorHandler.ts @@ -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 { + 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); +} diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts index 8ac2aedf2f..b233f01181 100644 --- a/frontend/src/desktop/services/operationRouter.ts +++ b/frontend/src/desktop/services/operationRouter.ts @@ -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 { 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 { 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; diff --git a/frontend/src/desktop/services/selfHostedServerMonitor.ts b/frontend/src/desktop/services/selfHostedServerMonitor.ts index f453193783..dfb4085a97 100644 --- a/frontend/src/desktop/services/selfHostedServerMonitor.ts +++ b/frontend/src/desktop/services/selfHostedServerMonitor.ts @@ -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 }); diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index 4ac5a70b3e..703fa9d388 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -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 | null = null; private startPromise: Promise | 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 | 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 { + 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; diff --git a/frontend/src/proprietary/routes/authShared/auth.css b/frontend/src/proprietary/routes/authShared/auth.css index 7f89d4a4d2..b5b5b882b1 100644 --- a/frontend/src/proprietary/routes/authShared/auth.css +++ b/frontend/src/proprietary/routes/authShared/auth.css @@ -193,12 +193,12 @@ align-items: center; justify-content: space-between; padding: 0.875rem 1.5rem; /* 14px 24px */ - border: 1px solid #e5e7eb; + border: 1px solid var(--auth-input-border-light-only); border-radius: 999px; - background-color: #ffffff; + background-color: var(--auth-card-bg-light-only); font-size: 1rem; /* 16px */ font-weight: 600; - color: #1f2937; + color: var(--auth-text-primary-light-only); cursor: pointer; gap: 1rem; /* 16px */ font-family: inherit; @@ -207,10 +207,10 @@ } .oauth-button-vertical:hover:not(:disabled) { - background-color: #f9fafb; + background-color: var(--hover-bg); box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12); transform: translateY(-1px); - border-color: #d1d5db; + border-color: var(--border-default); } .oauth-button-vertical-tinted { @@ -367,8 +367,8 @@ display: flex; align-items: center; justify-content: center; - background: #f3f4f6; - border: 1px solid #e5e7eb; + background: var(--bg-muted); + border: 1px solid var(--auth-input-border-light-only); } .oauth-button-vertical-tinted .oauth-icon-wrapper { @@ -407,11 +407,11 @@ } .sso-demo-card { - border: 1px solid rgba(15, 23, 42, 0.08); + border: 1px solid var(--auth-input-border-light-only); border-radius: 1rem; padding: 1rem; - background: #ffffff; - box-shadow: 0 0.25rem 0.75rem rgba(15, 23, 42, 0.06); + background: var(--auth-card-bg-light-only); + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.06); } .sso-demo-title { @@ -568,13 +568,13 @@ text-decoration: underline; cursor: pointer; font-size: 0.875rem; /* 14px */ - color: #000; + color: var(--auth-text-primary-light-only); } .auth-dot-black { opacity: 0.5; padding: 0 0.5rem; - color: #000; + color: var(--auth-text-primary-light-only); } /* Email login button - red CTA style matching SaaS version */ diff --git a/frontend/src/proprietary/styles/auth-theme.css b/frontend/src/proprietary/styles/auth-theme.css index abc474d127..9e3112eb54 100644 --- a/frontend/src/proprietary/styles/auth-theme.css +++ b/frontend/src/proprietary/styles/auth-theme.css @@ -22,3 +22,25 @@ --tool-subcategory-rule-color-light: #e5e7eb; --tool-subcategory-text-color-light: #9ca3af; } + +[data-mantine-color-scheme="dark"] { + --auth-bg-color-light-only: var(--bg-muted); + --auth-card-bg: var(--bg-surface); + --auth-card-bg-light-only: var(--bg-surface); + --auth-label-text-light-only: var(--text-secondary); + --auth-input-border-light-only: var(--border-default); + --auth-input-bg-light-only: var(--bg-raised); + --auth-input-text-light-only: var(--text-primary); + --auth-border-focus-light-only: #3b82f6; + --auth-focus-ring-light-only: rgba(59, 130, 246, 0.2); + --auth-button-bg-light-only: #AF3434; + --auth-button-text-light-only: #ffffff; + --auth-magic-button-bg-light-only: var(--bg-raised); + --auth-magic-button-text-light-only: var(--text-primary); + --auth-text-primary-light-only: var(--text-primary); + --auth-text-secondary-light-only: var(--text-secondary); + --text-divider-rule-rgb-light: 58, 64, 71; + --text-divider-label-rgb-light: 107, 114, 128; + --tool-subcategory-rule-color-light: var(--border-default); + --tool-subcategory-text-color-light: var(--text-secondary); +} diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts index 54c9405987..2edad06187 100644 --- a/frontend/vite-env.d.ts +++ b/frontend/vite-env.d.ts @@ -21,6 +21,8 @@ interface ImportMetaEnv { readonly VITE_DESKTOP_BACKEND_URL: string; readonly VITE_SAAS_SERVER_URL: string; readonly VITE_SAAS_BACKEND_API_URL: string; + /** When "true" (dev only), desktop auth treats JWT as expired β€” see authService.shouldSimulateExpiredJwt */ + readonly VITE_DEV_SIMULATE_EXPIRED_JWT: string; } interface ImportMeta {