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