Desktop connection SaaS: config, billing, team support (#5768)

Co-authored-by: James Brunton <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-02-25 14:13:07 +00:00
committed by GitHub
co-authored by James Brunton James Brunton
parent 86072ec91a
commit 5c39acecd8
76 changed files with 6231 additions and 119 deletions
@@ -0,0 +1,12 @@
/**
* Core stub for QuickAccessBar footer extensions
* Desktop build overrides this with actual credit counter implementation
*/
interface QuickAccessBarFooterExtensionsProps {
className?: string;
}
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) {
return null;
}
@@ -0,0 +1,11 @@
interface CloudBadgeProps {
className?: string;
}
/**
* Stub component for cloud badge (desktop override provides real implementation)
* In web builds, this returns null since cloud routing is desktop-only
*/
export function CloudBadge(_props: CloudBadgeProps) {
return null; // Stub - does nothing in web builds
}
@@ -27,6 +27,7 @@ import {
getActiveNavButton,
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
@@ -265,6 +266,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
<QuickAccessBarFooterExtensions className="quick-access-footer" />
{/* Bottom section */}
<Stack gap="lg" align="stretch">
{bottomButtons.map((buttonConfig, index) => {
@@ -0,0 +1,9 @@
/**
* Core stub for SaaS Teams Section
* Desktop layer provides the real implementation
* This component only appears in SaaS mode
*/
export function SaaSTeamsSection() {
// Core stub - return null (no team management in web builds)
return null;
}
@@ -0,0 +1,8 @@
/**
* Core stub for SaasPlanSection
* This component returns null in non-desktop builds
* The desktop layer shadows this with the real implementation
*/
export function SaasPlanSection() {
return null;
}
@@ -0,0 +1,13 @@
/**
* Core stub for Credit Exhausted Modal
* Desktop build overrides this with actual modal implementation
*/
interface CreditExhaustedModalProps {
opened: boolean;
onClose: () => void;
}
export function CreditExhaustedModal(_props: CreditExhaustedModalProps) {
return null;
}
@@ -0,0 +1,15 @@
/**
* Core stub for Insufficient Credits Modal
* Desktop build overrides this with actual modal implementation
*/
interface InsufficientCreditsModalProps {
opened: boolean;
onClose: () => void;
toolId?: string;
requiredCredits?: number;
}
export function InsufficientCreditsModal(_props: InsufficientCreditsModalProps) {
return null;
}
@@ -9,6 +9,7 @@ import { useFileSelection } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus";
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
@@ -63,7 +64,18 @@ const ConvertSettings = ({
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
// Get comprehensive conversion status (availability + cloud routing)
const conversionStatus = useConversionCloudStatus();
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
const conversionKey = `${fromExt}-${toExt}`;
// In desktop SaaS mode, check combined availability (local OR SaaS)
if (conversionStatus.availability[conversionKey] !== undefined) {
return conversionStatus.availability[conversionKey];
}
// Fallback to local-only check (web mode or desktop non-SaaS mode)
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false;
@@ -71,6 +83,10 @@ const ConvertSettings = ({
return isAvailable;
};
const doesConversionUseCloud = (fromExt: string, toExt: string): boolean => {
return conversionStatus.cloudStatus[`${fromExt}-${toExt}`] || false;
};
// Enhanced FROM options with endpoint availability
const enhancedFromOptions = useMemo(() => {
const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
@@ -107,18 +123,20 @@ const ConvertSettings = ({
}
return filteredOptions;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
// Enhanced TO options with endpoint availability
// Enhanced TO options with endpoint availability and cloud status
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
const enhanced = availableOptions.map(option => {
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
const usesCloud = doesConversionUseCloud(parameters.fromExtension, option.value);
return {
...option,
enabled
enabled,
usesCloud
};
});
@@ -128,7 +146,7 @@ const ConvertSettings = ({
}
return enhanced;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
interface FormatOption {
@@ -8,6 +9,7 @@ interface FormatOption {
label: string;
group: string;
enabled?: boolean;
usesCloud?: boolean;
}
interface GroupedFormatDropdownProps {
@@ -145,10 +147,20 @@ const GroupedFormatDropdown = ({
fontSize: '0.75rem',
height: '2rem',
padding: '0 0.75rem',
flexShrink: 0
flexShrink: 0,
position: 'relative'
}}
>
{option.label}
{option.usesCloud && (
<CloudOutlinedIcon
style={{
fontSize: '0.625rem',
marginLeft: '0.25rem',
opacity: 0.7
}}
/>
)}
</Button>
))}
</Group>
@@ -1,7 +1,8 @@
import { Button } from '@mantine/core';
import { Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { CloudBadge } from '@app/components/shared/CloudBadge';
export interface OperationButtonProps {
onClick?: () => void;
@@ -14,6 +15,7 @@ export interface OperationButtonProps {
fullWidth?: boolean;
mt?: string;
type?: 'button' | 'submit' | 'reset';
showCloudBadge?: boolean;
'data-testid'?: string;
'data-tour'?: string;
}
@@ -29,6 +31,7 @@ const OperationButton = ({
fullWidth = false,
mt = 'md',
type = 'button',
showCloudBadge = false,
'data-testid': dataTestId,
'data-tour': dataTour
}: OperationButtonProps) => {
@@ -54,12 +57,17 @@ const OperationButton = ({
color={color}
data-testid={dataTestId}
data-tour={dataTour}
style={{ minHeight: '2.5rem' }}
style={{ minHeight: '2.5rem', position: 'relative' }}
>
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
{showCloudBadge && (
<Box style={{ position: 'absolute', top: 4, right: 4 }}>
<CloudBadge />
</Box>
)}
</Button>
);
@@ -38,6 +38,7 @@ export interface ExecuteButtonConfig {
isVisible?: boolean;
disabled?: boolean;
testId?: string;
showCloudBadge?: boolean;
}
export interface ReviewStepConfig<TParams = unknown> {
@@ -105,6 +106,7 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
disabled={config.executeButton.disabled}
loadingText={config.executeButton.loadingText}
submitText={config.executeButton.text}
showCloudBadge={config.executeButton.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
data-testid={config.executeButton.testId}
data-tour="run-button"
/>
@@ -14,6 +14,8 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { CloudBadge } from "@app/components/shared/CloudBadge";
import { useToolCloudStatus } from "@app/hooks/useToolCloudStatus";
interface ToolButtonProps {
id: ToolId;
@@ -38,6 +40,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const { getToolNavigation } = useToolNavigation();
const fav = isFavorite(id as ToolId);
// Check if this tool will route to SaaS backend (desktop only)
const endpointName = tool.endpoints?.[0];
const usesCloud = useToolCloudStatus(endpointName);
const handleClick = (id: ToolId) => {
if (isUnavailable) return;
if (tool.link) {
@@ -98,6 +104,9 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
{t('toolPanel.alpha', 'Alpha')}
</Badge>
)}
{usesCloud && !isUnavailable && (
<CloudBadge />
)}
</div>
{matchedSynonym && (
<span style={{