mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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
|
# Desktop auth integration
|
||||||
VITE_SAAS_SERVER_URL=https://auth.stirling.com
|
VITE_SAAS_SERVER_URL=https://auth.stirling.com
|
||||||
VITE_SAAS_BACKEND_API_URL=https://api2.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"
|
security = "Security Configuration"
|
||||||
system = "System 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]
|
[convert]
|
||||||
autoRotate = "Auto Rotate"
|
autoRotate = "Auto Rotate"
|
||||||
autoRotateDescription = "Automatically rotate images to better fit the PDF page"
|
autoRotateDescription = "Automatically rotate images to better fit the PDF page"
|
||||||
@@ -5857,12 +5865,15 @@ systemSettings = "System Settings"
|
|||||||
title = "Configuration"
|
title = "Configuration"
|
||||||
|
|
||||||
[settings.connection]
|
[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"
|
logout = "Log Out"
|
||||||
server = "Server"
|
server = "Server"
|
||||||
|
signIn = "Sign In"
|
||||||
title = "Connection Mode"
|
title = "Connection Mode"
|
||||||
user = "Logged in as"
|
user = "Logged in as"
|
||||||
|
|
||||||
[settings.connection.mode]
|
[settings.connection.mode]
|
||||||
|
local = "Local Only"
|
||||||
saas = "Stirling Cloud"
|
saas = "Stirling Cloud"
|
||||||
selfhosted = "Self-Hosted"
|
selfhosted = "Self-Hosted"
|
||||||
|
|
||||||
@@ -6060,6 +6071,18 @@ title = "Workspace"
|
|||||||
[settings.team]
|
[settings.team]
|
||||||
title = "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]
|
[setup]
|
||||||
description = "Get started by choosing how you want to use Stirling PDF"
|
description = "Get started by choosing how you want to use Stirling PDF"
|
||||||
welcome = "Welcome to Stirling PDF"
|
welcome = "Welcome to Stirling PDF"
|
||||||
@@ -6067,6 +6090,7 @@ welcome = "Welcome to Stirling PDF"
|
|||||||
[setup.login]
|
[setup.login]
|
||||||
connectingTo = "Connecting to:"
|
connectingTo = "Connecting to:"
|
||||||
hideInstructions = "Hide instructions"
|
hideInstructions = "Hide instructions"
|
||||||
|
skipSignIn = "Continue without signing in"
|
||||||
instructions = "To enable login on your Stirling PDF server:"
|
instructions = "To enable login on your Stirling PDF server:"
|
||||||
instructionsEnvVar = "Set the environment variable:"
|
instructionsEnvVar = "Set the environment variable:"
|
||||||
instructionsOrYml = "Or in settings.yml:"
|
instructionsOrYml = "Or in settings.yml:"
|
||||||
@@ -6114,8 +6138,15 @@ title = "Sign in to Stirling"
|
|||||||
[setup.selfhosted]
|
[setup.selfhosted]
|
||||||
link = "or connect to a self-hosted account"
|
link = "or connect to a self-hosted account"
|
||||||
subtitle = "Enter your server credentials"
|
subtitle = "Enter your server credentials"
|
||||||
|
switchToLocal = "Use local tools instead"
|
||||||
title = "Sign in to Server"
|
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]
|
[setup.server]
|
||||||
subtitle = "Enter your self-hosted server URL"
|
subtitle = "Enter your self-hosted server URL"
|
||||||
testing = "Testing connection..."
|
testing = "Testing connection..."
|
||||||
@@ -6123,7 +6154,8 @@ title = "Connect to Server"
|
|||||||
useLast = "Last used server: {{serverUrl}}"
|
useLast = "Last used server: {{serverUrl}}"
|
||||||
|
|
||||||
[setup.server.error]
|
[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"
|
emptyUrl = "Please enter a server URL"
|
||||||
invalidUrl = "Invalid URL format. Please enter a valid URL like https://your-server.com"
|
invalidUrl = "Invalid URL format. Please enter a valid URL like https://your-server.com"
|
||||||
testFailed = "Connection test failed"
|
testFailed = "Connection test failed"
|
||||||
@@ -6695,6 +6727,12 @@ removal = "Removal"
|
|||||||
signing = "Signing"
|
signing = "Signing"
|
||||||
verification = "Verification"
|
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]
|
[tools]
|
||||||
noSearchResults = "No tools found"
|
noSearchResults = "No tools found"
|
||||||
noTools = "No tools available"
|
noTools = "No tools available"
|
||||||
|
|||||||
@@ -398,6 +398,9 @@ pub async fn start_backend(
|
|||||||
ConnectionMode::SelfHosted => {
|
ConnectionMode::SelfHosted => {
|
||||||
add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
|
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
|
// Check if backend is already running or starting
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::sync::Mutex;
|
|||||||
pub enum ConnectionMode {
|
pub enum ConnectionMode {
|
||||||
SaaS,
|
SaaS,
|
||||||
SelfHosted,
|
SelfHosted,
|
||||||
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
type: 'modal-slide',
|
type: 'modal-slide',
|
||||||
slideId: 'welcome',
|
slideId: 'welcome',
|
||||||
condition: () => true,
|
// Desktop has its own onboarding modal (DesktopOnboardingModal)
|
||||||
|
condition: (ctx) => !ctx.isDesktopApp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'admin-overview',
|
id: 'admin-overview',
|
||||||
@@ -107,7 +108,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
id: 'tour-overview',
|
id: 'tour-overview',
|
||||||
type: 'modal-slide',
|
type: 'modal-slide',
|
||||||
slideId: 'tour-overview',
|
slideId: 'tour-overview',
|
||||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
|
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin' && !ctx.isDesktopApp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'server-license',
|
id: 'server-license',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ToolButton from "@app/components/tools/toolPicker/ToolButton";
|
|||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { ToolId } from "@app/types/toolId";
|
import { ToolId } from "@app/types/toolId";
|
||||||
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
|
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
|
||||||
|
import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/ToolPickerFooterExtensions";
|
||||||
|
|
||||||
interface ToolPickerProps {
|
interface ToolPickerProps {
|
||||||
selectedToolKey: string | null;
|
selectedToolKey: string | null;
|
||||||
@@ -150,6 +151,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<ToolPickerFooterExtensions />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||||
import { CloudBadge } from '@app/components/shared/CloudBadge';
|
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 {
|
export interface OperationButtonProps {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disabledReason?: ExecuteDisabledReason;
|
||||||
loadingText?: string;
|
loadingText?: string;
|
||||||
submitText?: string;
|
submitText?: string;
|
||||||
variant?: 'filled' | 'outline' | 'subtle';
|
variant?: 'filled' | 'outline' | 'subtle';
|
||||||
@@ -24,6 +27,7 @@ const OperationButton = ({
|
|||||||
onClick,
|
onClick,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
disabledReason,
|
||||||
loadingText,
|
loadingText,
|
||||||
submitText,
|
submitText,
|
||||||
variant = 'filled',
|
variant = 'filled',
|
||||||
@@ -37,20 +41,32 @@ const OperationButton = ({
|
|||||||
}: OperationButtonProps) => {
|
}: OperationButtonProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isOnline, message: backendMessage } = useBackendHealth();
|
const { isOnline, message: backendMessage } = useBackendHealth();
|
||||||
|
const { onEndpointUnavailableClick } = useToolActions();
|
||||||
const blockedByBackend = !isOnline;
|
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
|
const tooltipLabel = blockedByBackend
|
||||||
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))
|
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))
|
||||||
: null;
|
: (disabledReason ? (reasonTooltip[disabledReason] ?? null) : null);
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth || !!tooltipLabel}
|
||||||
mr='md'
|
mr={tooltipLabel ? 0 : 'md'}
|
||||||
ml='md'
|
ml={tooltipLabel ? 0 : 'md'}
|
||||||
mt={mt}
|
mt={tooltipLabel ? 0 : mt}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={combinedDisabled}
|
disabled={combinedDisabled}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
@@ -72,9 +88,22 @@ const OperationButton = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (tooltipLabel) {
|
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 (
|
return (
|
||||||
<Tooltip content={tooltipLabel} position="top" arrow>
|
<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>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
|||||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
|
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
|
||||||
import { StirlingFile } from '@app/types/fileContext';
|
import { StirlingFile } from '@app/types/fileContext';
|
||||||
import type { TooltipTip } from '@app/types/tips';
|
import type { TooltipTip } from '@app/types/tips';
|
||||||
|
import type { ExecuteDisabledReason } from '@app/hooks/tools/shared/toolOperationTypes';
|
||||||
|
|
||||||
export interface FilesStepConfig {
|
export interface FilesStepConfig {
|
||||||
selectedFiles: StirlingFile[];
|
selectedFiles: StirlingFile[];
|
||||||
@@ -36,6 +37,23 @@ export interface ExecuteButtonConfig {
|
|||||||
loadingText: string;
|
loadingText: string;
|
||||||
onClick: () => Promise<void>;
|
onClick: () => Promise<void>;
|
||||||
isVisible?: boolean;
|
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;
|
disabled?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
showCloudBadge?: boolean;
|
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}
|
{!config.review.isVisible && (config.files.selectedFiles?.length ?? 0) > 0 && config.preview}
|
||||||
|
|
||||||
{/* Execute Button */}
|
{/* Execute Button */}
|
||||||
{config.executeButton && config.executeButton.isVisible !== false && (
|
{config.executeButton && config.executeButton.isVisible !== false && (() => {
|
||||||
<OperationButton
|
const eb = config.executeButton;
|
||||||
onClick={config.executeButton.onClick}
|
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
|
||||||
isLoading={config.review.operation.isLoading}
|
// Compute the disabled reason from structured fields; explicit disabledReason wins if set.
|
||||||
disabled={config.executeButton.disabled}
|
const effectiveDisabledReason: ExecuteDisabledReason =
|
||||||
loadingText={config.executeButton.loadingText}
|
eb.disabledReason !== undefined ? eb.disabledReason
|
||||||
submitText={config.executeButton.text}
|
: eb.endpointEnabled === false ? 'endpointUnavailable'
|
||||||
showCloudBadge={config.executeButton.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
|
: !hasFiles ? 'noFiles'
|
||||||
data-testid={config.executeButton.testId}
|
: eb.paramsValid === false ? 'invalidParams'
|
||||||
data-tour="run-button"
|
: 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 */}
|
{/* Review Step */}
|
||||||
{steps.createReviewStep({
|
{steps.createReviewStep({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useHotkeys } from "@app/contexts/HotkeyContext";
|
|||||||
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
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 { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { CloudBadge } from "@app/components/shared/CloudBadge";
|
import { CloudBadge } from "@app/components/shared/CloudBadge";
|
||||||
@@ -26,15 +26,21 @@ interface ToolButtonProps {
|
|||||||
disableNavigation?: boolean;
|
disableNavigation?: boolean;
|
||||||
matchedSynonym?: string;
|
matchedSynonym?: string;
|
||||||
hasStars?: boolean;
|
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 { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const premiumEnabled = config?.premiumEnabled;
|
const premiumEnabled = config?.premiumEnabled;
|
||||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability, premiumEnabled);
|
const disabledReason = getToolDisabledReason(id, tool, toolAvailability, premiumEnabled);
|
||||||
const isUnavailable = disabledReason !== null;
|
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 { hotkeys } = useHotkeys();
|
||||||
const binding = hotkeys[id];
|
const binding = hotkeys[id];
|
||||||
const { getToolNavigation } = useToolNavigation();
|
const { getToolNavigation } = useToolNavigation();
|
||||||
@@ -46,7 +52,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
|||||||
const usesCloud = useWillUseCloud(endpointString);
|
const usesCloud = useWillUseCloud(endpointString);
|
||||||
|
|
||||||
const handleClick = (id: ToolId) => {
|
const handleClick = (id: ToolId) => {
|
||||||
if (isUnavailable) return;
|
if (isUnavailable) {
|
||||||
|
onUnavailableClick?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (tool.link) {
|
if (tool.link) {
|
||||||
// Open external link in new tab
|
// Open external link in new tab
|
||||||
window.open(tool.link, '_blank', 'noopener,noreferrer');
|
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 { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||||
const disabledMessage = t(disabledKey, disabledFallback);
|
const disabledMessage = t(disabledKey, disabledFallback);
|
||||||
|
|
||||||
const tooltipContent = isUnavailable
|
const tooltipContent = visuallyUnavailable
|
||||||
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
|
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
|
||||||
: (
|
: (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
@@ -84,7 +93,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
|||||||
<>
|
<>
|
||||||
<ToolIcon
|
<ToolIcon
|
||||||
icon={tool.icon}
|
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', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
|
<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}
|
lines={1}
|
||||||
minimumFontScale={0.8}
|
minimumFontScale={0.8}
|
||||||
as="span"
|
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' && (
|
{tool.versionStatus === 'alpha' && (
|
||||||
<Badge
|
<Badge
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="orange"
|
color="orange"
|
||||||
style={{ flexShrink: 0, opacity: isUnavailable ? 0.25 : 1 }}
|
style={{ flexShrink: 0, opacity: visuallyUnavailable ? 0.25 : 1 }}
|
||||||
>
|
>
|
||||||
{t('toolPanel.alpha', 'Alpha')}
|
{t('toolPanel.alpha', 'Alpha')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{usesCloud && !isUnavailable && (
|
{(usesCloud && !visuallyUnavailable) && (
|
||||||
<CloudBadge />
|
<CloudBadge />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +122,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
|||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
color: 'var(--mantine-color-dimmed)',
|
color: 'var(--mantine-color-dimmed)',
|
||||||
opacity: isUnavailable ? 0.25 : 1,
|
opacity: visuallyUnavailable ? 0.25 : 1,
|
||||||
marginTop: '1px',
|
marginTop: '1px',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
@@ -195,7 +204,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
|||||||
root: {
|
root: {
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
color: "var(--tools-text-and-icon-color)",
|
color: "var(--tools-text-and-icon-color)",
|
||||||
cursor: isUnavailable ? 'not-allowed' : undefined,
|
cursor: visuallyUnavailable ? 'not-allowed' : undefined,
|
||||||
overflow: 'visible'
|
overflow: 'visible'
|
||||||
},
|
},
|
||||||
label: { overflow: 'visible' }
|
label: { overflow: 'visible' }
|
||||||
@@ -205,7 +214,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const star = hasStars && !isUnavailable ? (
|
const star = hasStars && !visuallyUnavailable ? (
|
||||||
<FavoriteStar
|
<FavoriteStar
|
||||||
isFavorite={fav}
|
isFavorite={fav}
|
||||||
onToggle={() => toggleFavorite(id as ToolId)}
|
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)
|
// Workflow Actions (compound actions)
|
||||||
handleToolSelect: (toolId: ToolId) => void;
|
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;
|
handleBackToTools: () => void;
|
||||||
handleReaderToggle: () => void;
|
handleReaderToggle: () => void;
|
||||||
|
|
||||||
@@ -321,6 +325,23 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
setReaderMode(false); // Disable read mode when selecting tools
|
setReaderMode(false); // Disable read mode when selecting tools
|
||||||
}, [actions, getSelectedTool, navigationState.workbench, navigationState.hasUnsavedChanges, navigationState.selectedTool, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
|
}, [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(() => {
|
const handleBackToTools = useCallback(() => {
|
||||||
setLeftPanelView('toolPicker');
|
setLeftPanelView('toolPicker');
|
||||||
setReaderMode(false);
|
setReaderMode(false);
|
||||||
@@ -378,6 +399,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
|
|
||||||
// Workflow Actions
|
// Workflow Actions
|
||||||
handleToolSelect,
|
handleToolSelect,
|
||||||
|
handleToolSelectForced,
|
||||||
handleBackToTools,
|
handleBackToTools,
|
||||||
handleReaderToggle,
|
handleReaderToggle,
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ export enum ToolType {
|
|||||||
custom,
|
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.
|
* Result from custom processor with optional metadata about input consumption.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
// Endpoint validation
|
// Endpoint validation
|
||||||
endpointEnabled,
|
endpointEnabled,
|
||||||
endpointLoading,
|
endpointLoading,
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
handleExecute,
|
handleExecute,
|
||||||
handleThumbnailClick,
|
handleThumbnailClick,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const accountService = {
|
|||||||
* Get current user account data
|
* Get current user account data
|
||||||
*/
|
*/
|
||||||
async getAccountData(): Promise<AccountData> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class SignatureStorageService {
|
|||||||
// Probe the proprietary signatures endpoint (requires authentication)
|
// Probe the proprietary signatures endpoint (requires authentication)
|
||||||
await apiClient.get('/api/v1/proprietary/signatures', {
|
await apiClient.get('/api/v1/proprietary/signatures', {
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
|
suppressErrorToast: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 200 = Backend available and accessible (authenticated)
|
// 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)
|
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
|
||||||
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
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)
|
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
|
||||||
export const Z_INDEX_TOAST = 10001;
|
export const Z_INDEX_TOAST = 10001;
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
|||||||
isVisible: !hasResults,
|
isVisible: !hasResults,
|
||||||
loadingText: t('loading'),
|
loadingText: t('loading'),
|
||||||
onClick: handleExecute,
|
onClick: handleExecute,
|
||||||
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults,
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
|||||||
isVisible: !hasResults,
|
isVisible: !hasResults,
|
||||||
loadingText: t('loading'),
|
loadingText: t('loading'),
|
||||||
onClick: handleExecute,
|
onClick: handleExecute,
|
||||||
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults,
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
isVisible: !hasResults,
|
isVisible: !hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleAddPassword,
|
onClick: handleAddPassword,
|
||||||
disabled: !addPasswordParams.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: addPasswordParams.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults,
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
isVisible: !hasResults,
|
isVisible: !hasResults,
|
||||||
loadingText: t('loading'),
|
loadingText: t('loading'),
|
||||||
onClick: handleExecute,
|
onClick: handleExecute,
|
||||||
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults,
|
||||||
|
|||||||
@@ -199,7 +199,8 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
isVisible: !hasResults,
|
isVisible: !hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleAddWatermark,
|
onClick: handleAddWatermark,
|
||||||
disabled: !watermarkParams.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: watermarkParams.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const AdjustContrast = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t('loading'),
|
loadingText: t('loading'),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.hasFiles,
|
paramsValid: true,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const AdjustPageScale = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ return createToolFlow({
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const BookletImposition = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ const CertSign = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ const ChangeMetadata = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const ChangePermissions = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const Compress = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
loadingText: t("convert.converting", "Converting..."),
|
loadingText: t("convert.converting", "Converting..."),
|
||||||
onClick: handleConvert,
|
onClick: handleConvert,
|
||||||
isVisible: !hasResults,
|
isVisible: !hasResults,
|
||||||
disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: convertParams.validateParameters(),
|
||||||
testId: "convert-button",
|
testId: "convert-button",
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const Crop = (props: BaseToolProps) => {
|
|||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ const ExtractImages = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ const ExtractPages = (props: BaseToolProps) => {
|
|||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const Flatten = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -163,11 +163,8 @@ const GetPdfInfo = (props: BaseToolProps) => {
|
|||||||
text: t('getPdfInfo.submit', 'Generate'),
|
text: t('getPdfInfo.submit', 'Generate'),
|
||||||
loadingText: t('loading', 'Loading...'),
|
loadingText: t('loading', 'Loading...'),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled:
|
endpointEnabled: base.endpointEnabled,
|
||||||
!base.params.validateParameters() ||
|
paramsValid: base.params.validateParameters(),
|
||||||
!base.hasFiles ||
|
|
||||||
base.operation.isLoading ||
|
|
||||||
!base.endpointEnabled,
|
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ const Merge = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -128,8 +128,9 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
text: t("ocr.operation.submit", "Process OCR and Review"),
|
text: t("ocr.operation.submit", "Process OCR and Review"),
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleOCR,
|
onClick: handleOCR,
|
||||||
isVisible: hasValidSettings && !hasResults,
|
isVisible: !hasResults,
|
||||||
disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: hasValidSettings,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const OverlayPdfs = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t('loading'),
|
loadingText: t('loading'),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ const PageLayout = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t('loading'),
|
loadingText: t('loading'),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const RemoveAnnotations = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading", "Processing..."),
|
loadingText: t("loading", "Processing..."),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles,
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ const RemoveBlanks = (props: BaseToolProps) => {
|
|||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const RemoveCertificateSign = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const RemoveImage = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ const RemovePages = (props: BaseToolProps) => {
|
|||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const RemovePassword = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps)
|
|||||||
isVisible: !hasResults,
|
isVisible: !hasResults,
|
||||||
loadingText: t('loading'),
|
loadingText: t('loading'),
|
||||||
onClick: handleExecute,
|
onClick: handleExecute,
|
||||||
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
endpointEnabled: endpointEnabled,
|
||||||
|
paramsValid: params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const Repair = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const ReplaceColor = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ const Rotate = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ const Sanitize = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const ScannerImageSplit = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const SingleLargePage = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ const Split = (props: BaseToolProps) => {
|
|||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const UnlockPdfForms = (props: BaseToolProps) => {
|
|||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
|
paramsValid: base.params.validateParameters(),
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
|
|||||||
@@ -142,11 +142,8 @@ const ValidateSignature = (props: BaseToolProps) => {
|
|||||||
text: t('validateSignature.submit', 'Validate Signatures'),
|
text: t('validateSignature.submit', 'Validate Signatures'),
|
||||||
loadingText: t('loading', 'Loading...'),
|
loadingText: t('loading', 'Loading...'),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled:
|
endpointEnabled: base.endpointEnabled,
|
||||||
!base.params.validateParameters() ||
|
paramsValid: base.params.validateParameters(),
|
||||||
!base.hasFiles ||
|
|
||||||
base.operation.isLoading ||
|
|
||||||
!base.endpointEnabled,
|
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
},
|
},
|
||||||
review: {
|
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 { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||||
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||||
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
||||||
import { SaveShortcutListener } from '@app/components/SaveShortcutListener';
|
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 { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
|
||||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||||
import { connectionModeService } from '@app/services/connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||||
import { authService } from '@app/services/authService';
|
import { authService } from '@app/services/authService';
|
||||||
@@ -41,25 +45,116 @@ const COMMON_TOOL_ENDPOINTS = [
|
|||||||
*/
|
*/
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
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 [authChecked, setAuthChecked] = useState(false);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
// When auth check finds no valid session, record the sign-in detail here so the
|
||||||
// Load connection mode on mount
|
// 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(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
|
// Wait until connection mode is loaded before checking auth
|
||||||
|
if (connectionMode === null) return;
|
||||||
|
|
||||||
if (!isFirstLaunch && setupComplete) {
|
if (!isFirstLaunch && setupComplete) {
|
||||||
authService.isAuthenticated()
|
if (connectionMode === 'local') {
|
||||||
.then(setIsAuthenticated)
|
// Even in local mode, check for a valid JWT — on Windows, the OAuth callback
|
||||||
.catch(() => setIsAuthenticated(false))
|
// can complete without switchToSaaS() being called (race condition), leaving
|
||||||
.finally(() => setAuthChecked(true));
|
// 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) {
|
} else if (isFirstLaunch && !setupComplete) {
|
||||||
setAuthChecked(true);
|
// Auto-enter local mode on first launch — skip the setup wizard entirely.
|
||||||
setIsAuthenticated(false);
|
// 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
|
// Initialize backend health monitoring for self-hosted mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,7 +175,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Initialize monitoring for bundled backend (already started in Rust)
|
// Initialize monitoring for bundled backend (already started in Rust)
|
||||||
// This sets up port detection and health checks
|
// This sets up port detection and health checks
|
||||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
const shouldMonitorBackend = setupComplete && !isFirstLaunch && (connectionMode === 'saas' || connectionMode === 'local');
|
||||||
useBackendInitializer(shouldMonitorBackend);
|
useBackendInitializer(shouldMonitorBackend);
|
||||||
|
|
||||||
// Preload endpoint availability for the local bundled backend.
|
// 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).
|
// individual requests per-tool when the remote server goes offline).
|
||||||
const shouldPreloadLocalEndpoints =
|
const shouldPreloadLocalEndpoints =
|
||||||
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
|
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
|
||||||
|
(setupComplete && !isFirstLaunch && connectionMode === 'local') ||
|
||||||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
|
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldPreloadLocalEndpoints) return;
|
if (!shouldPreloadLocalEndpoints) return;
|
||||||
@@ -109,6 +205,15 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [shouldPreloadLocalEndpoints, connectionMode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return;
|
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
|
// Normal app flow
|
||||||
return (
|
return (
|
||||||
<ProprietaryAppProviders
|
<ProprietaryAppProviders
|
||||||
appConfigRetryOptions={{
|
appConfigRetryOptions={{
|
||||||
maxRetries: 5,
|
maxRetries: 5,
|
||||||
initialDelay: 1000, // 1 second, with exponential backoff
|
initialDelay: 1000,
|
||||||
}}
|
}}
|
||||||
appConfigProviderProps={{
|
appConfigProviderProps={{
|
||||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||||
@@ -204,17 +263,25 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
autoFetch: false,
|
autoFetch: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SaaSTeamProvider>
|
<ToolActionsContext.Provider value={{
|
||||||
<SaasBillingProvider>
|
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
|
||||||
<SaaSCheckoutProvider>
|
}}>
|
||||||
<DesktopConfigSync />
|
<SaaSTeamProvider key={appKey}>
|
||||||
<DesktopBannerInitializer />
|
<SaasBillingProvider>
|
||||||
<SaveShortcutListener />
|
<SaaSCheckoutProvider>
|
||||||
<CreditModalBootstrap />
|
<DesktopConfigSync />
|
||||||
{children}
|
<DesktopBannerInitializer />
|
||||||
</SaaSCheckoutProvider>
|
<SaveShortcutListener />
|
||||||
</SaasBillingProvider>
|
<CreditModalBootstrap />
|
||||||
</SaaSTeamProvider>
|
{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>
|
</ProprietaryAppProviders>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
|
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
|
||||||
import { authService, UserInfo } from '@app/services/authService';
|
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 = () => {
|
export const ConnectionSettings: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,31 +24,31 @@ export const ConnectionSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
|
|
||||||
|
const unsubscribe = connectionModeService.subscribeToModeChanges(loadConfig);
|
||||||
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await authService.logout();
|
// Save server URL before clearing so user can easily reconnect (self-hosted only)
|
||||||
|
if (config?.mode === 'selfhosted' && config?.server_config?.url) {
|
||||||
if (!config?.lock_connection_mode) {
|
localStorage.setItem('server_url', config.server_config.url);
|
||||||
// Switch to SaaS mode
|
|
||||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
|
||||||
|
|
||||||
// Reset setup completion to force login screen on reload
|
|
||||||
await connectionModeService.resetSetupCompletion();
|
|
||||||
}
|
}
|
||||||
|
await authService.logout();
|
||||||
|
// Always switch to local after logout so the app remains usable
|
||||||
|
await connectionModeService.switchToLocal();
|
||||||
|
|
||||||
// Reload config
|
// Reload config
|
||||||
const newConfig = await connectionModeService.getCurrentConfig();
|
const newConfig = await connectionModeService.getCurrentConfig();
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setUserInfo(null);
|
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({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
|
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||||
// Reload the page to clear all state and show login screen
|
// connectionModeService subscription when mode changes to local.
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
console.error('Logout failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,6 +56,10 @@ export const ConnectionSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSignIn = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
|
||||||
|
};
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <Text>{t('common.loading', 'Loading...')}</Text>;
|
return <Text>{t('common.loading', 'Loading...')}</Text>;
|
||||||
}
|
}
|
||||||
@@ -66,13 +70,27 @@ export const ConnectionSettings: React.FC = () => {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
|
<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'
|
{config.mode === 'saas'
|
||||||
? t('settings.connection.mode.saas', 'Stirling Cloud')
|
? 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>
|
</Badge>
|
||||||
</Group>
|
</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 && (
|
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -99,9 +117,15 @@ export const ConnectionSettings: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Group mt="md">
|
<Group mt="md">
|
||||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
{config.mode === 'local' ? (
|
||||||
{t('settings.connection.logout', 'Log Out')}
|
<Button onClick={handleSignIn} color="blue" variant="light">
|
||||||
</Button>
|
{t('settings.connection.signIn', 'Sign In')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||||
|
{t('settings.connection.logout', 'Log Out')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</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>;
|
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||||
onSelfHostedClick: () => void;
|
onSelfHostedClick: () => void;
|
||||||
onSwitchToSignup: () => void;
|
onSwitchToSignup: () => void;
|
||||||
|
onSkipSignIn?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,8 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
|||||||
onOAuthSuccess,
|
onOAuthSuccess,
|
||||||
onSelfHostedClick,
|
onSelfHostedClick,
|
||||||
onSwitchToSignup,
|
onSwitchToSignup,
|
||||||
|
onSkipSignIn,
|
||||||
|
onClose,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -57,7 +61,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
|||||||
|
|
||||||
return (
|
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} />
|
<ErrorMessage error={displayError} />
|
||||||
|
|
||||||
@@ -110,6 +114,19 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
<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}`;
|
url = `https://${url}`;
|
||||||
setCustomUrl(url); // Update the input field
|
setCustomUrl(url); // Update the input field
|
||||||
}
|
}
|
||||||
localStorage.setItem('server_url', url);
|
|
||||||
|
|
||||||
// Validate URL format
|
// Validate URL format
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
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);
|
console.error('[ServerSelection] Configuration fetch error details:', errorMessage);
|
||||||
|
|
||||||
setTestError(
|
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
|
error: errorMessage
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -158,7 +156,8 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
|||||||
return;
|
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');
|
console.log('[ServerSelection] ✅ Server selection complete, proceeding to login');
|
||||||
onSelect({
|
onSelect({
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Stack, Text, Button, Alert, Loader, Center } from '@mantine/core';
|
||||||
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
||||||
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
||||||
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
|
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
|
||||||
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
||||||
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
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 { AuthServiceError, authService, UserInfo } from '@app/services/authService';
|
||||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||||
@@ -21,9 +22,13 @@ enum SetupStep {
|
|||||||
|
|
||||||
interface SetupWizardProps {
|
interface SetupWizardProps {
|
||||||
onComplete: () => void;
|
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 { t } = useTranslation();
|
||||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
|
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
|
||||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
|
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 [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
|
||||||
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
|
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
|
||||||
const [lockConnectionMode, setLockConnectionMode] = useState(false);
|
const [lockConnectionMode, setLockConnectionMode] = useState(false);
|
||||||
|
const [lockedServerUnreachable, setLockedServerUnreachable] = useState(false);
|
||||||
|
const [lockedServerChecking, setLockedServerChecking] = useState(false);
|
||||||
|
|
||||||
const handleSaaSLogin = async (username: string, password: string) => {
|
const handleSaaSLogin = async (username: string, password: string) => {
|
||||||
if (!serverConfig) {
|
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 = () => {
|
const handleSelfHostedClick = () => {
|
||||||
if (lockConnectionMode) {
|
if (lockConnectionMode) {
|
||||||
return;
|
return;
|
||||||
@@ -285,58 +310,73 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const loadLockedConfig = useCallback(async () => {
|
||||||
const loadConfig = async () => {
|
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
if (!currentConfig.lock_connection_mode) return;
|
||||||
if (currentConfig.lock_connection_mode && currentConfig.server_config?.url) {
|
// server_config may be null when the user switched to local mode from a locked deployment.
|
||||||
setLockConnectionMode(true);
|
// 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
|
setLockConnectionMode(true);
|
||||||
const savedUrl = currentConfig.server_config.url.replace(/\/+$/, ''); // Remove trailing slashes
|
setLockedServerUnreachable(false);
|
||||||
let updatedConfig = { ...currentConfig.server_config };
|
setLockedServerChecking(true);
|
||||||
|
|
||||||
try {
|
const savedUrl = serverUrl.replace(/\/+$/, '');
|
||||||
console.log('[SetupWizard] Re-fetching OAuth providers for saved server:', savedUrl);
|
let updatedConfig: ServerConfig = { ...(currentConfig.server_config ?? { url: savedUrl }) };
|
||||||
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
|
|
||||||
|
|
||||||
if (response.ok) {
|
try {
|
||||||
const data = await response.json();
|
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
|
||||||
const enabledProviders: any[] = [];
|
|
||||||
const providerEntries = Object.entries(data.providerList || {});
|
|
||||||
|
|
||||||
providerEntries.forEach(([path, label]) => {
|
if (response.ok) {
|
||||||
const id = path.split('/').pop();
|
const data = await response.json();
|
||||||
if (id) {
|
const enabledProviders: SSOProviderConfig[] = [];
|
||||||
enabledProviders.push({
|
const providerEntries = Object.entries(data.providerList || {});
|
||||||
id,
|
|
||||||
path,
|
providerEntries.forEach(([path, label]) => {
|
||||||
label: typeof label === 'string' ? label : undefined,
|
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);
|
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);
|
setActiveStep(SetupStep.SelfHostedLogin);
|
||||||
}
|
}
|
||||||
};
|
} catch (err) {
|
||||||
|
// Network error — server is unreachable
|
||||||
void loadConfig();
|
console.error('[SetupWizard] Server unreachable:', err);
|
||||||
|
setServerConfig(updatedConfig);
|
||||||
|
setLockedServerChecking(false);
|
||||||
|
setLockedServerUnreachable(true);
|
||||||
|
setActiveStep(SetupStep.SelfHostedLogin);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<DesktopAuthLayout>
|
void loadLockedConfig();
|
||||||
|
}, [loadLockedConfig]);
|
||||||
|
|
||||||
|
const wizardContent = (
|
||||||
|
<>
|
||||||
{/* Step Content */}
|
{/* Step Content */}
|
||||||
{!lockConnectionMode && activeStep === SetupStep.SaaSLogin && (
|
{!lockConnectionMode && activeStep === SetupStep.SaaSLogin && (
|
||||||
<SaaSLoginScreen
|
<SaaSLoginScreen
|
||||||
@@ -345,6 +385,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||||
onSelfHostedClick={handleSelfHostedClick}
|
onSelfHostedClick={handleSelfHostedClick}
|
||||||
onSwitchToSignup={handleSwitchToSignup}
|
onSwitchToSignup={handleSwitchToSignup}
|
||||||
|
onSkipSignIn={handleLocalMode}
|
||||||
|
onClose={onClose}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
@@ -367,19 +409,68 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === SetupStep.SelfHostedLogin && (
|
{lockConnectionMode && lockedServerChecking && (
|
||||||
<SelfHostedLoginScreen
|
<Center py="xl">
|
||||||
serverUrl={serverConfig?.url || ''}
|
<Loader size="md" />
|
||||||
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
</Center>
|
||||||
loginMethod={serverConfig?.loginMethod}
|
)}
|
||||||
onLogin={handleSelfHostedLogin}
|
|
||||||
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
{activeStep === SetupStep.SelfHostedLogin && lockedServerUnreachable && !lockedServerChecking && (
|
||||||
mfaCode={selfHostedMfaCode}
|
<Stack gap="md" style={{ padding: '0.5rem 0' }}>
|
||||||
setMfaCode={setSelfHostedMfaCode}
|
<Alert color="orange" title={t('setup.selfhosted.unreachable.title', 'Cannot connect to server')}>
|
||||||
requiresMfa={selfHostedMfaRequired}
|
<Text size="sm">
|
||||||
loading={loading}
|
{t('setup.selfhosted.unreachable.message', 'Could not reach {{url}}. Check that the server is running and accessible.', {
|
||||||
error={error}
|
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 */}
|
{/* Back Button */}
|
||||||
@@ -394,6 +485,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noLayout) {
|
||||||
|
return <div style={{ padding: '2rem' }}>{wizardContent}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DesktopAuthLayout>
|
||||||
|
{wizardContent}
|
||||||
</DesktopAuthLayout>
|
</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 { useState, useEffect, useMemo } from 'react';
|
||||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
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 {
|
interface RightRailFooterExtensionsProps {
|
||||||
className?: string;
|
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) {
|
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -17,7 +110,7 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
|||||||
paddingBottom: rem(12),
|
paddingBottom: rem(12),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BackendHealthIndicator />
|
<ConnectionStatusDot />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ export function SelfHostedOfflineBanner() {
|
|||||||
() => !!tauriBackendService.getBackendUrl()
|
() => !!tauriBackendService.getBackendUrl()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load connection mode once on mount
|
// Load connection mode and keep it live via subscription
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||||
|
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Subscribe to self-hosted server status changes
|
// Subscribe to self-hosted server status changes
|
||||||
|
|||||||
@@ -20,23 +20,12 @@ export const useConfigNavSections = (
|
|||||||
): ConfigNavSection[] => {
|
): ConfigNavSection[] => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Check if in SaaS mode and authenticated (for Team section visibility)
|
const [connectionMode, setConnectionMode] = useState<string | null>(null);
|
||||||
const [isSaasMode, setIsSaasMode] = useState<boolean>(false);
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAccess = async () => {
|
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||||
const auth = await authService.isAuthenticated();
|
|
||||||
setIsSaasMode(mode === 'saas');
|
|
||||||
setIsAuthenticated(auth);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAccess();
|
|
||||||
|
|
||||||
// Subscribe to connection mode changes
|
|
||||||
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
|
|
||||||
return unsubscribe;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Subscribe to auth changes
|
// Subscribe to auth changes
|
||||||
@@ -47,9 +36,33 @@ export const useConfigNavSections = (
|
|||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isSaasMode = connectionMode === 'saas';
|
||||||
|
const isLocalMode = connectionMode === 'local';
|
||||||
|
|
||||||
// Get the proprietary sections (includes core Preferences + admin sections)
|
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||||
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
|
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.
|
// Identifies self-hosted admin sections by their first item's stable key.
|
||||||
// Using item keys avoids dependency on translated section titles (#17).
|
// Using item keys avoids dependency on translated section titles (#17).
|
||||||
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
|
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
|
||||||
@@ -67,17 +80,7 @@ export const useConfigNavSections = (
|
|||||||
if (sections.length > 0) result.push(sections[0]);
|
if (sections.length > 0) result.push(sections[0]);
|
||||||
|
|
||||||
// Connection Mode always sits immediately after Preferences
|
// Connection Mode always sits immediately after Preferences
|
||||||
result.push({
|
result.push(connectionModeSection);
|
||||||
title: t('settings.connection.title', 'Connection Mode'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'connectionMode',
|
|
||||||
label: t('settings.connection.title', 'Connection Mode'),
|
|
||||||
icon: 'desktop-cloud-rounded',
|
|
||||||
component: <ConnectionSettings />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Plan & Billing and Team sections only when authenticated in SaaS mode
|
// Plan & Billing and Team sections only when authenticated in SaaS mode
|
||||||
if (isSaasMode && isAuthenticated) {
|
if (isSaasMode && isAuthenticated) {
|
||||||
@@ -106,12 +109,17 @@ export const useConfigNavSections = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Append remaining proprietary sections, skipping self-hosted admin sections in SaaS mode
|
// 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)) {
|
for (const section of sections.slice(1)) {
|
||||||
const firstItemKey = section.items[0]?.key;
|
const firstItemKey = section.items[0]?.key;
|
||||||
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
|
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
|
||||||
continue;
|
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;
|
return result;
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export function CreditModalBootstrap() {
|
|||||||
toolId: customEvent.detail?.operationType,
|
toolId: customEvent.detail?.operationType,
|
||||||
requiredCredits: customEvent.detail?.requiredCredits,
|
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);
|
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 {
|
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);
|
setTeams(response.data);
|
||||||
|
|
||||||
const activeTeam = response.data[0];
|
const activeTeam = response.data[0];
|
||||||
@@ -144,7 +144,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
setTeamMembers(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
|
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
|
||||||
@@ -158,7 +158,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
setTeamInvitations(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SaaSTeamContext] Failed to fetch team invitations:', 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');
|
console.log('[SaaSTeamContext] Fetching received team invitations');
|
||||||
|
|
||||||
try {
|
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);
|
console.log('[SaaSTeamContext] Received invitations response:', response.data);
|
||||||
setReceivedInvitations(response.data);
|
setReceivedInvitations(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { connectionModeService } from '@app/services/connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
|
||||||
|
|
||||||
type SignOutFn = () => Promise<void>;
|
type SignOutFn = () => Promise<void>;
|
||||||
|
|
||||||
@@ -17,13 +16,16 @@ export function useAccountLogout() {
|
|||||||
await signOut();
|
await signOut();
|
||||||
|
|
||||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||||
if (!currentConfig.lock_connection_mode) {
|
// Save server URL before clearing so user can easily reconnect (self-hosted only)
|
||||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
if (currentConfig.mode === 'selfhosted' && currentConfig.server_config?.url) {
|
||||||
await connectionModeService.resetSetupCompletion().catch(() => {});
|
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.history.replaceState({}, '', '/');
|
||||||
window.location.reload();
|
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||||
|
// connectionModeService subscription when mode changes to local.
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
|
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function useBackendHealth() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||||
|
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -106,13 +106,13 @@ export function useEndpointEnabled(endpoint: string): {
|
|||||||
|
|
||||||
const locallyEnabled = response.data;
|
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) {
|
if (!locallyEnabled) {
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
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') {
|
if (mode === 'saas') {
|
||||||
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
|
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
|
||||||
setEnabled(true); // Available via SaaS
|
setEnabled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,9 +302,10 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, boolean>);
|
}, {} as Record<string, boolean>);
|
||||||
|
|
||||||
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
|
||||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
|
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
|
||||||
// They will route to SaaS backend
|
// They will route to SaaS backend
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
|
||||||
if (mode === 'saas') {
|
if (mode === 'saas') {
|
||||||
const disabledEndpoints = Object.keys(details).filter(key => !details[key].enabled);
|
const disabledEndpoints = Object.keys(details).filter(key => !details[key].enabled);
|
||||||
|
|
||||||
@@ -315,6 +316,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||||
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
|
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
|
||||||
} catch (err: unknown) {
|
} 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 { authService } from '@app/services/authService';
|
||||||
import { connectionModeService } from '@app/services/connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
|
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';
|
import i18n from '@app/i18n';
|
||||||
|
|
||||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||||
@@ -52,8 +53,6 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
|
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('server_url', baseUrl);
|
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||||
|
|
||||||
@@ -162,6 +161,12 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
if (originalRequest.skipAuthRedirect) {
|
if (originalRequest.skipAuthRedirect) {
|
||||||
return Promise.reject(error);
|
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;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
console.debug(`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`);
|
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);
|
return client.request(originalRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh failed - user needs to login again
|
// Refresh failed - prompt for re-authentication via the sign-in modal.
|
||||||
alert({
|
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }));
|
||||||
alertType: 'error',
|
|
||||||
title: i18n.t('auth.sessionExpired', 'Session Expired'),
|
|
||||||
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
|
|
||||||
isPersistentPopup: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 403 Forbidden - unauthorized access
|
// Handle 403 Forbidden - unauthorized access
|
||||||
|
|||||||
@@ -192,6 +192,22 @@ export class AuthService {
|
|||||||
this.notifyListeners();
|
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> {
|
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
|
||||||
if (!accessToken || !accessToken.trim()) {
|
if (!accessToken || !accessToken.trim()) {
|
||||||
throw new Error('Invalid access token');
|
throw new Error('Invalid access token');
|
||||||
@@ -455,6 +471,12 @@ export class AuthService {
|
|||||||
|
|
||||||
// Cache token if found (backend will validate expiry)
|
// Cache token if found (backend will validate expiry)
|
||||||
if (token && token.trim().length > 0) {
|
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;
|
this.cachedToken = token;
|
||||||
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
||||||
return token;
|
return token;
|
||||||
@@ -507,6 +529,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isTokenExpiringSoon(token: string, leewaySeconds = 30): boolean {
|
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 {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
@@ -718,8 +746,11 @@ export class AuthService {
|
|||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
console.log('[Desktop AuthService] Auth state initialized as unauthenticated');
|
console.log('[Desktop AuthService] Auth state initialized as unauthenticated');
|
||||||
|
|
||||||
// Defensive: ensure any partial tokens are purged to prevent auto-login loops
|
// Defensive: ensure any partial tokens are purged to prevent auto-login loops.
|
||||||
await this.clearTokenEverywhere().catch(() => {});
|
// 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 { fetch } from '@tauri-apps/plugin-http';
|
||||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||||
|
|
||||||
export type ConnectionMode = 'saas' | 'selfhosted';
|
export type ConnectionMode = 'saas' | 'selfhosted' | 'local';
|
||||||
|
|
||||||
export interface SSOProviderConfig {
|
export interface SSOProviderConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +36,8 @@ export interface ConnectionTestResult {
|
|||||||
diagnostics?: DiagnosticResult[];
|
diagnostics?: DiagnosticResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LOCAL_MODE_STORAGE_KEY = 'stirling-local-mode';
|
||||||
|
|
||||||
export class ConnectionModeService {
|
export class ConnectionModeService {
|
||||||
private static instance: ConnectionModeService;
|
private static instance: ConnectionModeService;
|
||||||
private currentConfig: ConnectionConfig | null = null;
|
private currentConfig: ConnectionConfig | null = null;
|
||||||
@@ -82,12 +84,35 @@ export class ConnectionModeService {
|
|||||||
private async loadConfig(): Promise<void> {
|
private async loadConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const config = await invoke<ConnectionConfig>('get_connection_config');
|
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.currentConfig = config;
|
||||||
this.configLoadedOnce = true;
|
this.configLoadedOnce = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load connection config:', error);
|
console.error('Failed to load connection config:', error);
|
||||||
// Default to SaaS mode on error
|
// Default to local mode on error — safer than showing SaaS UI for a
|
||||||
this.currentConfig = { mode: 'saas', server_config: null, lock_connection_mode: false };
|
// desktop app whose bundled backend is always available.
|
||||||
|
this.currentConfig = { mode: 'local', server_config: null, lock_connection_mode: false };
|
||||||
this.configLoadedOnce = true;
|
this.configLoadedOnce = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +122,9 @@ export class ConnectionModeService {
|
|||||||
throw new Error('Connection mode is locked by provisioning');
|
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');
|
console.log('Switching to SaaS mode');
|
||||||
|
|
||||||
const serverConfig: ServerConfig = { url: saasServerUrl };
|
const serverConfig: ServerConfig = { url: saasServerUrl };
|
||||||
@@ -117,7 +145,37 @@ export class ConnectionModeService {
|
|||||||
console.log('Switched to SaaS mode successfully');
|
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> {
|
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);
|
console.log('Switching to self-hosted mode:', serverConfig);
|
||||||
|
|
||||||
await invoke('set_connection_mode', {
|
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();
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
|
||||||
// Current implementation: simple mode-based routing
|
// Current implementation: simple mode-based routing
|
||||||
if (mode === 'saas') {
|
if (mode === 'saas' || mode === 'local') {
|
||||||
// SaaS mode: For now, all operations run locally
|
|
||||||
// Future enhancement: complex operations will be sent to SaaS server
|
|
||||||
return 'local';
|
return 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +131,35 @@ export class OperationRouter {
|
|||||||
async getBaseUrl(operation?: string): Promise<string> {
|
async getBaseUrl(operation?: string): Promise<string> {
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
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)
|
// Always route team endpoints to SaaS backend (existing logic)
|
||||||
if (mode === 'saas' && this.isSaaSBackendEndpoint(operation)) {
|
if (mode === 'saas' && this.isSaaSBackendEndpoint(operation)) {
|
||||||
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
||||||
@@ -148,39 +175,47 @@ export class OperationRouter {
|
|||||||
const endpointToCheck = this.extractEndpointName(operation);
|
const endpointToCheck = this.extractEndpointName(operation);
|
||||||
console.debug(`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`);
|
console.debug(`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`);
|
||||||
|
|
||||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
const backendUrl = tauriBackendService.getBackendUrl();
|
||||||
endpointToCheck,
|
const backendHealthy = tauriBackendService.isOnline;
|
||||||
tauriBackendService.getBackendUrl()
|
|
||||||
);
|
|
||||||
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
|
|
||||||
|
|
||||||
if (!supportedLocally) {
|
// If the local backend isn't ready (no URL yet, or not yet healthy), skip the
|
||||||
// Local backend doesn't support this - check if SaaS supports it
|
// capability check and fall through to local routing — the backend-readiness check
|
||||||
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
|
// in the Axios interceptor will block non-GET requests until the backend is healthy.
|
||||||
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
|
if (backendUrl && backendHealthy) {
|
||||||
|
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||||
|
endpointToCheck,
|
||||||
|
backendUrl
|
||||||
|
);
|
||||||
|
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
|
||||||
|
|
||||||
if (!supportedOnSaaS) {
|
if (!supportedLocally) {
|
||||||
// Neither local nor SaaS support this - throw error
|
// Local backend doesn't support this - check if SaaS supports it
|
||||||
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
|
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
|
||||||
throw new Error(
|
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
|
||||||
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`
|
|
||||||
);
|
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
|
// Supported locally - continue with local backend
|
||||||
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
|
||||||
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)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-hosted fallback: when the remote server is offline, route tool endpoints
|
// 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
|
// NEW: Skip if endpoint will be routed to SaaS due to local unavailability
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
if (mode === 'saas' && endpoint && this.isToolEndpoint(endpoint)) {
|
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 endpointToCheck = this.extractEndpointName(endpoint);
|
||||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||||
endpointToCheck,
|
endpointToCheck,
|
||||||
tauriBackendService.getBackendUrl()
|
backendUrl
|
||||||
);
|
);
|
||||||
return !supportedLocally; // Skip check if not supported locally
|
return !supportedLocally; // Skip check if not supported locally
|
||||||
}
|
}
|
||||||
@@ -288,7 +325,9 @@ export class OperationRouter {
|
|||||||
*/
|
*/
|
||||||
async willRouteToSaaS(endpoint: string): Promise<boolean> {
|
async willRouteToSaaS(endpoint: string): Promise<boolean> {
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
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
|
// Team endpoints always go to SaaS
|
||||||
if (this.isSaaSBackendEndpoint(endpoint)) return true;
|
if (this.isSaaSBackendEndpoint(endpoint)) return true;
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ class SelfHostedServerMonitor {
|
|||||||
connectTimeout: REQUEST_TIMEOUT_MS,
|
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 });
|
this.updateState({ status: 'online', isOnline: true });
|
||||||
} else {
|
} else {
|
||||||
this.updateState({ status: 'offline', isOnline: false });
|
this.updateState({ status: 'offline', isOnline: false });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { fetch } from '@tauri-apps/plugin-http';
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import { alert } from '@app/components/toast';
|
||||||
|
|
||||||
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||||
|
|
||||||
@@ -11,6 +12,12 @@ export class TauriBackendService {
|
|||||||
private healthMonitor: Promise<void> | null = null;
|
private healthMonitor: Promise<void> | null = null;
|
||||||
private startPromise: Promise<void> | null = null;
|
private startPromise: Promise<void> | null = null;
|
||||||
private statusListeners = new Set<(status: BackendStatus) => void>();
|
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 {
|
static getInstance(): TauriBackendService {
|
||||||
if (!TauriBackendService.instance) {
|
if (!TauriBackendService.instance) {
|
||||||
@@ -52,6 +59,79 @@ export class TauriBackendService {
|
|||||||
}
|
}
|
||||||
this.backendStatus = status;
|
this.backendStatus = status;
|
||||||
this.statusListeners.forEach(listener => listener(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isLocalBackend = true; // We own this backend process
|
||||||
|
|
||||||
if (this.startPromise) {
|
if (this.startPromise) {
|
||||||
return this.startPromise;
|
return this.startPromise;
|
||||||
}
|
}
|
||||||
@@ -190,6 +272,13 @@ export class TauriBackendService {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
this.backendStarted = false;
|
this.backendStarted = false;
|
||||||
this.backendPort = null;
|
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.setStatus('stopped');
|
||||||
this.healthMonitor = null;
|
this.healthMonitor = null;
|
||||||
this.startPromise = null;
|
this.startPromise = null;
|
||||||
|
|||||||
@@ -193,12 +193,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.875rem 1.5rem; /* 14px 24px */
|
padding: 0.875rem 1.5rem; /* 14px 24px */
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--auth-input-border-light-only);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background-color: #ffffff;
|
background-color: var(--auth-card-bg-light-only);
|
||||||
font-size: 1rem; /* 16px */
|
font-size: 1rem; /* 16px */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: var(--auth-text-primary-light-only);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
gap: 1rem; /* 16px */
|
gap: 1rem; /* 16px */
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -207,10 +207,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.oauth-button-vertical:hover:not(:disabled) {
|
.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);
|
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: #d1d5db;
|
border-color: var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.oauth-button-vertical-tinted {
|
.oauth-button-vertical-tinted {
|
||||||
@@ -367,8 +367,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f3f4f6;
|
background: var(--bg-muted);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--auth-input-border-light-only);
|
||||||
}
|
}
|
||||||
|
|
||||||
.oauth-button-vertical-tinted .oauth-icon-wrapper {
|
.oauth-button-vertical-tinted .oauth-icon-wrapper {
|
||||||
@@ -407,11 +407,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sso-demo-card {
|
.sso-demo-card {
|
||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
border: 1px solid var(--auth-input-border-light-only);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #ffffff;
|
background: var(--auth-card-bg-light-only);
|
||||||
box-shadow: 0 0.25rem 0.75rem rgba(15, 23, 42, 0.06);
|
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sso-demo-title {
|
.sso-demo-title {
|
||||||
@@ -568,13 +568,13 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem; /* 14px */
|
font-size: 0.875rem; /* 14px */
|
||||||
color: #000;
|
color: var(--auth-text-primary-light-only);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-dot-black {
|
.auth-dot-black {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
color: #000;
|
color: var(--auth-text-primary-light-only);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Email login button - red CTA style matching SaaS version */
|
/* Email login button - red CTA style matching SaaS version */
|
||||||
|
|||||||
@@ -22,3 +22,25 @@
|
|||||||
--tool-subcategory-rule-color-light: #e5e7eb;
|
--tool-subcategory-rule-color-light: #e5e7eb;
|
||||||
--tool-subcategory-text-color-light: #9ca3af;
|
--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_DESKTOP_BACKEND_URL: string;
|
||||||
readonly VITE_SAAS_SERVER_URL: string;
|
readonly VITE_SAAS_SERVER_URL: string;
|
||||||
readonly VITE_SAAS_BACKEND_API_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 {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user