Desktop/remove hard requirement auth wall on desktop (#5956)

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