Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
+11 -15
View File
@@ -1,5 +1,5 @@
import { createClient } from '@supabase/supabase-js';
import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
import { createClient } from "@supabase/supabase-js";
import { STIRLING_SAAS_URL, SUPABASE_KEY } from "@app/constants/connection";
/**
* Supabase client for desktop application
@@ -10,21 +10,17 @@ import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
*/
if (!STIRLING_SAAS_URL) {
console.warn('[Desktop Supabase] VITE_SAAS_SERVER_URL not configured - SaaS features will not work');
console.warn("[Desktop Supabase] VITE_SAAS_SERVER_URL not configured - SaaS features will not work");
}
if (!SUPABASE_KEY) {
console.warn('[Desktop Supabase] VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY not configured - SaaS features will not work');
console.warn("[Desktop Supabase] VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY not configured - SaaS features will not work");
}
export const supabase = createClient(
STIRLING_SAAS_URL || '',
SUPABASE_KEY || '',
{
auth: {
persistSession: false, // Desktop manages auth via authService + Tauri secure store
autoRefreshToken: false, // Desktop manually refreshes tokens via authService
detectSessionInUrl: false, // Desktop uses deep links, not URL hash fragments
},
}
);
export const supabase = createClient(STIRLING_SAAS_URL || "", SUPABASE_KEY || "", {
auth: {
persistSession: false, // Desktop manages auth via authService + Tauri secure store
autoRefreshToken: false, // Desktop manually refreshes tokens via authService
detectSessionInUrl: false, // Desktop uses deep links, not URL hash fragments
},
});
@@ -1,40 +1,40 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { SaveShortcutListener } from '@app/components/SaveShortcutListener';
import { DesktopOnboardingModal } from '@app/components/DesktopOnboardingModal';
import { SignInModal } from '@app/components/SignInModal';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
import { ToolActionsContext } from '@app/contexts/ToolActionsContext';
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService, JWT_EXPIRED_PROMPTED_KEY } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { authService } from '@app/services/authService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauri } from '@tauri-apps/api/core';
import { SaaSTeamProvider } from '@app/contexts/SaaSTeamContext';
import { SaasBillingProvider } from '@app/contexts/SaasBillingContext';
import { SaaSCheckoutProvider } from '@app/contexts/SaaSCheckoutContext';
import { CreditModalBootstrap } from '@app/components/shared/modals/CreditModalBootstrap';
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal";
import { SignInModal } from "@app/components/SignInModal";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import { ToolActionsContext } from "@app/contexts/ToolActionsContext";
import { useFirstLaunchCheck } from "@app/hooks/useFirstLaunchCheck";
import { useBackendInitializer } from "@app/hooks/useBackendInitializer";
import { DESKTOP_DEFAULT_APP_CONFIG } from "@app/config/defaultAppConfig";
import { connectionModeService, JWT_EXPIRED_PROMPTED_KEY } from "@app/services/connectionModeService";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { authService } from "@app/services/authService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { isTauri } from "@tauri-apps/api/core";
import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
// Common tool endpoints to preload for faster first-use
const COMMON_TOOL_ENDPOINTS = [
'/api/v1/misc/compress-pdf',
'/api/v1/general/merge-pdfs',
'/api/v1/general/split-pages',
'/api/v1/convert/pdf/img',
'/api/v1/convert/img/pdf',
'/api/v1/general/rotate-pdf',
'/api/v1/misc/add-watermark',
'/api/v1/security/add-password',
'/api/v1/security/remove-password',
'/api/v1/general/extract-pages',
"/api/v1/misc/compress-pdf",
"/api/v1/general/merge-pdfs",
"/api/v1/general/split-pages",
"/api/v1/convert/pdf/img",
"/api/v1/convert/img/pdf",
"/api/v1/general/rotate-pdf",
"/api/v1/misc/add-watermark",
"/api/v1/security/add-password",
"/api/v1/security/remove-password",
"/api/v1/general/extract-pages",
];
/**
@@ -45,7 +45,7 @@ const COMMON_TOOL_ENDPOINTS = [
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | 'local' | null>(null);
const [connectionMode, setConnectionMode] = useState<"saas" | "selfhosted" | "local" | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [pendingSignIn, setPendingSignIn] = useState(false);
// Prevent first-launch setup from running twice when connectionMode state update re-triggers the effect
@@ -71,8 +71,8 @@ export function AppProviders({ children }: { children: ReactNode }) {
// 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);
if (hasLoadedInitialMode.current && config.mode !== "selfhosted") {
setAppKey((k) => k + 1);
}
});
return unsub;
@@ -83,33 +83,35 @@ export function AppProviders({ children }: { children: ReactNode }) {
if (connectionMode === null) return;
if (!isFirstLaunch && setupComplete) {
if (connectionMode === 'local') {
if (connectionMode === "local") {
// Even in local mode, check for a valid JWT — on Windows, the OAuth callback
// can complete without switchToSaaS() being called (race condition), leaving
// LOCAL_MODE_STORAGE_KEY set while the user has a valid session. Upgrade to
// SaaS mode automatically so credits/billing/team features work correctly.
authService.isAuthenticated()
authService
.isAuthenticated()
.then(async (isAuth) => {
if (isAuth) {
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL).catch(console.error);
setConnectionMode('saas');
setConnectionMode("saas");
}
})
.finally(() => setAuthChecked(true));
} else {
authService.isAuthenticated()
authService
.isAuthenticated()
.then(async (isAuth) => {
if (!isAuth) {
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
if (!cfg?.lock_connection_mode) {
// JWT expired — fall back to local so local tools still work.
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode('local');
setConnectionMode("local");
// Show sign-in modal once per expiry cycle. If the user dismisses
// without signing in the flag stays set and we won't prompt again
// until they successfully sign in (which clears the flag).
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, 'true');
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
setPendingSignIn(true);
}
}
@@ -121,9 +123,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
if (!cfg?.lock_connection_mode) {
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode('local');
setConnectionMode("local");
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, 'true');
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
setPendingSignIn(true);
}
}
@@ -134,20 +136,21 @@ export function AppProviders({ children }: { children: ReactNode }) {
// Guard against re-running when setConnectionMode triggers this effect.
if (firstLaunchInitiated.current) return;
firstLaunchInitiated.current = true;
connectionModeService.getCurrentConfig()
connectionModeService
.getCurrentConfig()
.then(async (cfg) => {
if (cfg.lock_connection_mode && cfg.server_config?.url) {
// Locked provisioned deployment — do NOT switch to local (would clear server_config
// from the store). Show onboarding normally; the sign-in slide handles locked auth.
// Still start the local backend so local tools work while the user signs in.
await tauriBackendService.startBackend().catch(console.error);
setConnectionMode('selfhosted');
setConnectionMode("selfhosted");
} else {
// Normal first launch — auto-enter local mode.
// The onboarding carousel + sign-in slide will be shown inside the main app.
await connectionModeService.switchToLocal();
await tauriBackendService.startBackend();
setConnectionMode('local');
setConnectionMode("local");
}
})
.catch(console.error)
@@ -157,14 +160,14 @@ export function AppProviders({ children }: { children: ReactNode }) {
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
if (connectionMode !== 'selfhosted') {
if (connectionMode !== "selfhosted") {
// Stop the monitor whenever we leave selfhosted mode so the dot resets.
selfHostedServerMonitor.stop();
return;
}
if (setupComplete && !isFirstLaunch) {
void tauriBackendService.initializeExternalBackend();
connectionModeService.getServerConfig().then(cfg => {
connectionModeService.getServerConfig().then((cfg) => {
if (cfg?.url) {
selfHostedServerMonitor.start(cfg.url);
}
@@ -177,7 +180,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
// Initialize monitoring for bundled backend (already started in Rust)
// This sets up port detection and health checks
const shouldMonitorBackend = setupComplete && !isFirstLaunch && (connectionMode === 'saas' || connectionMode === 'local');
const shouldMonitorBackend = setupComplete && !isFirstLaunch && (connectionMode === "saas" || connectionMode === "local");
useBackendInitializer(shouldMonitorBackend);
// Preload endpoint availability for the local bundled backend.
@@ -186,9 +189,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
// (so useSelfHostedToolAvailability can use the cache instead of making
// individual requests per-tool when the remote server goes offline).
const shouldPreloadLocalEndpoints =
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
(setupComplete && !isFirstLaunch && connectionMode === 'local') ||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
(setupComplete && !isFirstLaunch && connectionMode === "saas") ||
(setupComplete && !isFirstLaunch && connectionMode === "local") ||
(setupComplete && !isFirstLaunch && connectionMode === "selfhosted");
useEffect(() => {
if (!shouldPreloadLocalEndpoints) return;
@@ -198,7 +201,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
// tauriBackendService.isOnline now always reflects the local backend.
// Wait for it to be healthy before preloading in both modes.
if (!tauriBackendService.isOnline) return;
console.debug('[AppProviders] Preloading common tool endpoints for local backend');
console.debug("[AppProviders] Preloading common tool endpoints for local backend");
void endpointAvailabilityService.preloadEndpoints(COMMON_TOOL_ENDPOINTS, backendUrl);
};
@@ -207,7 +210,6 @@ export function AppProviders({ children }: { children: ReactNode }) {
return unsubscribe;
}, [shouldPreloadLocalEndpoints, connectionMode]);
// Dispatch sign-in modal after authChecked so SignInModal's listener is registered.
// (Child effects run before parent effects, so this fires after SignInModal mounts.)
// detail.locked is always false here: setPendingSignIn(true) is only called inside
@@ -246,11 +248,11 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
bootstrapMode: "non-blocking",
autoFetch: false,
}}
>
<div style={{ minHeight: '100vh' }} />
<div style={{ minHeight: "100vh" }} />
</ProprietaryAppProviders>
);
}
@@ -264,13 +266,15 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
bootstrapMode: "non-blocking",
autoFetch: false,
}}
>
<ToolActionsContext.Provider value={{
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
}}>
<ToolActionsContext.Provider
value={{
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
}}
>
<SaaSTeamProvider key={appKey}>
<SaasBillingProvider>
<SaaSCheckoutProvider>
@@ -1,48 +1,49 @@
import React, { useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Tooltip, useMantineTheme, useComputedColorScheme, rem } from '@mantine/core';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import React, { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Box, Tooltip, useMantineTheme, useComputedColorScheme, rem } from "@mantine/core";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
interface BackendHealthIndicatorProps {
className?: string;
}
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
className = ''
}) => {
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({ className = "" }) => {
const { t } = useTranslation();
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme('light');
const colorScheme = useComputedColorScheme("light");
const { status, isOnline, checkHealth } = useBackendHealth();
const label = useMemo(() => {
if (status === 'starting') {
return t('backendHealth.checking', 'Checking backend status...');
if (status === "starting") {
return t("backendHealth.checking", "Checking backend status...");
}
if (isOnline) {
return t('backendHealth.online', 'Backend Online');
return t("backendHealth.online", "Backend Online");
}
return t('backendHealth.offline', 'Backend Offline');
return t("backendHealth.offline", "Backend Offline");
}, [status, isOnline, t]);
const dotColor = useMemo(() => {
if (status === 'starting') {
return theme.colors.yellow?.[5] ?? '#fcc419';
if (status === "starting") {
return theme.colors.yellow?.[5] ?? "#fcc419";
}
if (isOnline) {
return theme.colors.green?.[5] ?? '#37b24d';
return theme.colors.green?.[5] ?? "#37b24d";
}
return theme.colors.red?.[6] ?? '#e03131';
return theme.colors.red?.[6] ?? "#e03131";
}, [status, isOnline, theme.colors.green, theme.colors.red, theme.colors.yellow]);
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
checkHealth();
}
}, [checkHealth]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
checkHealth();
}
},
[checkHealth],
);
return (
<Tooltip
@@ -51,7 +52,7 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
offset={12}
withArrow
withinPortal
color={colorScheme === 'dark' ? undefined : 'dark'}
color={colorScheme === "dark" ? undefined : "dark"}
>
<Box
component="span"
@@ -65,14 +66,12 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
style={{
width: rem(12),
height: rem(12),
borderRadius: '50%',
borderRadius: "50%",
backgroundColor: dotColor,
boxShadow: colorScheme === 'dark'
? '0 0 0 2px rgba(255, 255, 255, 0.18)'
: '0 0 0 2px rgba(0, 0, 0, 0.08)',
cursor: 'pointer',
display: 'inline-block',
outline: 'none',
boxShadow: colorScheme === "dark" ? "0 0 0 2px rgba(255, 255, 255, 0.18)" : "0 0 0 2px rgba(0, 0, 0, 0.08)",
cursor: "pointer",
display: "inline-block",
outline: "none",
}}
/>
</Tooltip>
@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
import React, { useState, useEffect } from "react";
import { Stack, Card, Badge, Button, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { connectionModeService, ConnectionConfig } from "@app/services/connectionModeService";
import { authService, UserInfo } from "@app/services/authService";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
@@ -17,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
const currentConfig = await connectionModeService.getCurrentConfig();
setConfig(currentConfig);
if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
if (currentConfig.mode === "saas" || currentConfig.mode === "selfhosted") {
const user = await authService.getUserInfo();
setUserInfo(user);
}
@@ -33,8 +33,8 @@ export const ConnectionSettings: React.FC = () => {
try {
setLoading(true);
// Save server URL before clearing so user can easily reconnect (self-hosted only)
if (config?.mode === 'selfhosted' && config?.server_config?.url) {
localStorage.setItem('server_url', config.server_config.url);
if (config?.mode === "selfhosted" && config?.server_config?.url) {
localStorage.setItem("server_url", config.server_config.url);
}
await authService.logout();
// Always switch to local after logout so the app remains usable
@@ -46,11 +46,11 @@ export const ConnectionSettings: React.FC = () => {
setUserInfo(null);
// 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
// connectionModeService subscription when mode changes to local.
} catch (error) {
console.error('Logout failed:', error);
console.error("Logout failed:", error);
} finally {
setLoading(false);
}
@@ -61,7 +61,7 @@ export const ConnectionSettings: React.FC = () => {
};
if (!config) {
return <Text>{t('common.loading', 'Loading...')}</Text>;
return <Text>{t("common.loading", "Loading...")}</Text>;
}
return (
@@ -69,43 +69,40 @@ export const ConnectionSettings: React.FC = () => {
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
<Badge
color={config.mode === 'saas' ? 'blue' : config.mode === 'local' ? 'white' : 'green'}
variant="light"
>
{config.mode === 'saas'
? t('settings.connection.mode.saas', 'Stirling Cloud')
: config.mode === 'local'
? t('settings.connection.mode.local', 'Local Only')
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
<Text fw={600}>{t("settings.connection.title", "Connection Mode")}</Text>
<Badge color={config.mode === "saas" ? "blue" : config.mode === "local" ? "white" : "green"} variant="light">
{config.mode === "saas"
? t("settings.connection.mode.saas", "Stirling Cloud")
: config.mode === "local"
? t("settings.connection.mode.local", "Local Only")
: t("settings.connection.mode.selfhosted", "Self-Hosted")}
</Badge>
</Group>
{config.mode === 'local' && (
{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.'
"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>
<Text size="sm" fw={500}>
{t('settings.connection.server', 'Server')}
{t("settings.connection.server", "Server")}
</Text>
<Text size="sm" c="dimmed">
{config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
{config.mode === "saas" ? "stirling.com" : config.server_config.url}
</Text>
</div>
{userInfo && (
<div>
<Text size="sm" fw={500}>
{t('settings.connection.user', 'Logged in as')}
{t("settings.connection.user", "Logged in as")}
</Text>
<Text size="sm" c="dimmed">
{userInfo.username}
@@ -117,13 +114,13 @@ export const ConnectionSettings: React.FC = () => {
)}
<Group mt="md">
{config.mode === 'local' ? (
{config.mode === "local" ? (
<Button onClick={handleSignIn} color="blue" variant="light">
{t('settings.connection.signIn', 'Sign In')}
{t("settings.connection.signIn", "Sign In")}
</Button>
) : (
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Log Out')}
{t("settings.connection.logout", "Log Out")}
</Button>
)}
</Group>
@@ -1,9 +1,9 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
import { TeamInvitationBanner } from '@app/components/shared/TeamInvitationBanner';
import { SelfHostedOfflineBanner } from '@app/components/shared/SelfHostedOfflineBanner';
import { useEffect } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import { DefaultAppBanner } from "@app/components/shared/DefaultAppBanner";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
import { TeamInvitationBanner } from "@app/components/shared/TeamInvitationBanner";
import { SelfHostedOfflineBanner } from "@app/components/shared/SelfHostedOfflineBanner";
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useEffect, useRef } from "react";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
import { useAppConfig } from "@app/contexts/AppConfigContext";
/**
* Desktop-only bridge that refetches the app config once the bundled backend
@@ -13,7 +13,7 @@ export function DesktopConfigSync() {
const previousStatus = useRef(status);
useEffect(() => {
if (status === 'healthy' && previousStatus.current !== 'healthy') {
if (status === "healthy" && previousStatus.current !== "healthy") {
refetch();
}
previousStatus.current = status;
@@ -1,19 +1,19 @@
import { useState, 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';
import { useState, 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 ONBOARDING_KEY = "stirling-desktop-onboarding-seen";
const SIGN_IN_GRADIENT: [string, string] = ['#3B82F6', '#7C3AED'];
const SIGN_IN_GRADIENT: [string, string] = ["#3B82F6", "#7C3AED"];
/**
* Desktop-specific onboarding modal.
@@ -26,7 +26,7 @@ export function DesktopOnboardingModal() {
const [step, setStep] = useState(0);
const dismissFinal = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
localStorage.setItem(ONBOARDING_KEY, "true");
setVisible(false);
// If the user dismissed the sign-in slide without authenticating, fall back to local mode
// so the app is usable without a server connection.
@@ -43,14 +43,13 @@ export function DesktopOnboardingModal() {
};
const handleComplete = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
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(), []);
@@ -71,38 +70,42 @@ export function DesktopOnboardingModal() {
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
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' }}>
<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'}
slideKey={step === 0 ? "desktop-welcome" : "desktop-sign-in"}
/>
<ActionIcon
onClick={handleClose}
radius="md"
size={36}
style={{
position: 'absolute',
position: "absolute",
top: 16,
right: 16,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(4px)',
backgroundColor: "rgba(255, 255, 255, 0.2)",
color: "white",
backdropFilter: "blur(4px)",
zIndex: 10,
}}
styles={{ root: { '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.3)' } } }}
styles={{ root: { "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.3)" } } }}
>
<CloseIcon fontSize="small" />
</ActionIcon>
@@ -118,20 +121,13 @@ export function DesktopOnboardingModal() {
</div>
{/* Body section */}
<div
className={styles.modalBody}
style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}
>
<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.title} ${styles.titleText}`}>{welcomeSlide.title}</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{welcomeSlide.body}
</div>
<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} />
@@ -141,12 +137,12 @@ export function DesktopOnboardingModal() {
onClick={() => setStep(1)}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}}
>
{t('onboarding.buttons.next', 'Next →')}
{t("onboarding.buttons.next", "Next →")}
</Button>
</Group>
</div>
@@ -155,11 +151,7 @@ export function DesktopOnboardingModal() {
// Sign-in slide
<Stack gap={12}>
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
<SetupWizard
noLayout
onComplete={handleComplete}
onClose={dismissFinal}
/>
<SetupWizard noLayout onComplete={handleComplete} onClose={dismissFinal} />
</Stack>
)}
</div>
@@ -1,5 +1,5 @@
import { useSaveShortcut } from '@app/hooks/useSaveShortcut';
import { useExitWarning } from '@app/hooks/useExitWarning';
import { useSaveShortcut } from "@app/hooks/useSaveShortcut";
import { useExitWarning } from "@app/hooks/useExitWarning";
/**
* Desktop-only component that sets up keyboard shortcuts and exit warnings
@@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
import buildLoginSlides from '@app/components/shared/loginSlides';
import styles from '@app/routes/authShared/AuthLayout.module.css';
import { useLogoVariant } from '@app/hooks/useLogoVariant';
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
interface DesktopAuthLayoutProps {
children: React.ReactNode;
@@ -19,15 +19,15 @@ export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
const previousColorScheme = htmlElement.getAttribute("data-mantine-color-scheme");
// Set light mode
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
htmlElement.setAttribute("data-mantine-color-scheme", "light");
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
htmlElement.setAttribute("data-mantine-color-scheme", previousColorScheme);
}
};
}, []);
@@ -44,28 +44,21 @@ export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
};
}, []);
return (
<div className={styles.authContainer}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
>
<div ref={cardRef} className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}>
<div className={styles.authLeftPanel}>
<div className={styles.authContent}>
{children}
</div>
<div className={styles.authContent}>{children}</div>
</div>
{!hideRightPanel && (
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
)}
{!hideRightPanel && <LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />}
</div>
</div>
);
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authService, UserInfo } from '@app/services/authService';
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
import { BASE_PATH } from '@app/constants/app';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import '@app/components/SetupWizard/desktopOAuth.css';
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { authService, UserInfo } from "@app/services/authService";
import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml";
import { BASE_PATH } from "@app/constants/app";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import "@app/components/SetupWizard/desktopOAuth.css";
type KnownProviderId = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
type KnownProviderId = "google" | "github" | "keycloak" | "azure" | "apple" | "oidc";
export type OAuthProviderId = KnownProviderId | string;
export interface DesktopSSOProvider {
@@ -21,7 +21,7 @@ interface DesktopOAuthButtonsProps {
isDisabled: boolean;
serverUrl: string;
providers: DesktopSSOProvider[];
mode?: 'saas' | 'selfHosted';
mode?: "saas" | "selfHosted";
}
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
@@ -30,7 +30,7 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
isDisabled,
serverUrl,
providers,
mode = 'saas',
mode = "saas",
}) => {
const { t } = useTranslation();
const [oauthLoading, setOauthLoading] = useState(false);
@@ -46,21 +46,20 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
// Build callback page HTML with translations and dark mode support
const successHtml = buildOAuthCallbackHtml({
title: t('oauth.success.title', 'Authentication Successful'),
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
title: t("oauth.success.title", "Authentication Successful"),
message: t("oauth.success.message", "You can close this window and return to Stirling PDF."),
isError: false,
});
const errorHtml = buildOAuthCallbackHtml({
title: t('oauth.error.title', 'Authentication Failed'),
message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'),
title: t("oauth.error.title", "Authentication Failed"),
message: t("oauth.error.message", "Authentication was not successful. You can close this window and try again."),
isError: true,
errorPlaceholder: true, // {error} will be replaced by Rust
});
const normalizedServer = serverUrl.replace(/\/+$/, '');
const usingSupabaseFlow =
mode === 'saas' || normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, '');
const normalizedServer = serverUrl.replace(/\/+$/, "");
const usingSupabaseFlow = mode === "saas" || normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, "");
const userInfo = usingSupabaseFlow
? await authService.loginWithOAuth(provider.id, serverUrl, successHtml, errorHtml)
: await authService.loginWithSelfHostedOAuth(provider.path || provider.id, serverUrl);
@@ -68,11 +67,10 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
// Call the onOAuthSuccess callback to complete setup
await onOAuthSuccess(userInfo);
} catch (error) {
console.error('OAuth login failed:', error);
console.error("OAuth login failed:", error);
const errorMessage = error instanceof Error
? error.message
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
const errorMessage =
error instanceof Error ? error.message : t("setup.login.error.oauthFailed", "OAuth login failed. Please try again.");
onError(errorMessage);
setOauthLoading(false);
@@ -80,22 +78,21 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
};
const providerConfig: Record<KnownProviderId, { label: string; file: string }> = {
google: { label: 'Google', file: 'google.svg' },
github: { label: 'GitHub', file: 'github.svg' },
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
azure: { label: 'Microsoft', file: 'microsoft.svg' },
apple: { label: 'Apple', file: 'apple.svg' },
oidc: { label: 'OpenID', file: 'oidc.svg' },
google: { label: "Google", file: "google.svg" },
github: { label: "GitHub", file: "github.svg" },
keycloak: { label: "Keycloak", file: "keycloak.svg" },
azure: { label: "Microsoft", file: "microsoft.svg" },
apple: { label: "Apple", file: "apple.svg" },
oidc: { label: "OpenID", file: "oidc.svg" },
};
const isKnownProvider = (id: OAuthProviderId): id is KnownProviderId =>
(id as KnownProviderId) in providerConfig;
const GENERIC_PROVIDER_ICON = 'oidc.svg';
const isKnownProvider = (id: OAuthProviderId): id is KnownProviderId => (id as KnownProviderId) in providerConfig;
const GENERIC_PROVIDER_ICON = "oidc.svg";
console.log('[DesktopOAuthButtons] Received providers:', providers);
console.log('[DesktopOAuthButtons] Mode:', mode, 'Server URL:', serverUrl);
console.log("[DesktopOAuthButtons] Received providers:", providers);
console.log("[DesktopOAuthButtons] Mode:", mode, "Server URL:", serverUrl);
if (providers.length === 0) {
console.warn('[DesktopOAuthButtons] No providers to display, returning null');
console.warn("[DesktopOAuthButtons] No providers to display, returning null");
return null;
}
@@ -105,15 +102,13 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
{providers
.filter((providerConfigEntry) => providerConfigEntry && providerConfigEntry.id)
.map((providerEntry) => {
const iconConfig = isKnownProvider(providerEntry.id)
? providerConfig[providerEntry.id]
: undefined;
const iconConfig = isKnownProvider(providerEntry.id) ? providerConfig[providerEntry.id] : undefined;
const label =
providerEntry.label ||
iconConfig?.label ||
(providerEntry.id
? providerEntry.id.charAt(0).toUpperCase() + providerEntry.id.slice(1)
: t('setup.login.sso', 'Single Sign-On'));
: t("setup.login.sso", "Single Sign-On"));
return (
<button
key={providerEntry.id}
@@ -136,8 +131,8 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
);
})}
{oauthLoading && (
<p style={{ margin: '0.5rem 0', fontSize: '0.875rem', color: '#6b7280', textAlign: 'center' }}>
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
<p style={{ margin: "0.5rem 0", fontSize: "0.875rem", color: "#6b7280", textAlign: "center" }}>
{t("setup.login.oauthPending", "Opening browser for authentication...")}
</p>
)}
</div>
@@ -1,13 +1,13 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
import DividerWithText from '@app/components/shared/DividerWithText';
import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons';
import { SelfHostedLink } from '@app/components/SetupWizard/SelfHostedLink';
import { UserInfo } from '@app/services/authService';
import '@app/routes/authShared/auth.css';
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import DividerWithText from "@app/components/shared/DividerWithText";
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink";
import { UserInfo } from "@app/services/authService";
import "@app/routes/authShared/auth.css";
interface SaaSLoginScreenProps {
serverUrl: string;
@@ -33,19 +33,19 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
error,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const handleEmailPasswordSubmit = async () => {
// Validation
if (!email.trim()) {
setValidationError(t('setup.login.error.emptyEmail', 'Please enter your email'));
setValidationError(t("setup.login.error.emptyEmail", "Please enter your email"));
return;
}
if (!password) {
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
setValidationError(t("setup.login.error.emptyPassword", "Please enter your password"));
return;
}
@@ -61,7 +61,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
return (
<>
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} onClose={onClose} />
<LoginHeader title={t("setup.saas.title", "Sign in to Stirling Cloud")} onClose={onClose} />
<ErrorMessage error={displayError} />
@@ -71,14 +71,11 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
isDisabled={loading}
serverUrl={serverUrl}
mode="saas"
providers={[
{ id: 'google' },
{ id: 'github' },
]}
providers={[{ id: "google" }, { id: "github" }]}
/>
<DividerWithText
text={t('setup.login.orContinueWith', 'Or continue with email')}
text={t("setup.login.orContinueWith", "Or continue with email")}
respondsToDarkMode={false}
opacity={0.4}
/>
@@ -96,10 +93,10 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
}}
onSubmit={handleEmailPasswordSubmit}
isSubmitting={loading}
submitButtonText={t('setup.login.submit', 'Login')}
submitButtonText={t("setup.login.submit", "Login")}
/>
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'right' }}>
<div className="navigation-link-container" style={{ marginTop: "0.5rem", textAlign: "right" }}>
<button
type="button"
onClick={() => {
@@ -109,21 +106,16 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
className="navigation-link-button"
disabled={loading}
>
{t('signup.signUp', 'Sign Up')}
{t("signup.signUp", "Sign Up")}
</button>
</div>
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
{onSkipSignIn && (
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'center' }}>
<button
type="button"
onClick={onSkipSignIn}
className="navigation-link-button"
disabled={loading}
>
{t('setup.login.skipSignIn', 'Continue without signing in')}
<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>
)}
@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import SignupForm from '@app/routes/signup/SignupForm';
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation';
import { authService } from '@app/services/authService';
import '@app/routes/authShared/auth.css';
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import SignupForm from "@app/routes/signup/SignupForm";
import { useSignupFormValidation, SignupFieldErrors } from "@app/routes/signup/SignupFormValidation";
import { authService } from "@app/services/authService";
import "@app/routes/authShared/auth.css";
interface SaaSSignupScreenProps {
loading: boolean;
@@ -21,9 +21,9 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
onSwitchToLogin: _onSwitchToLogin,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>({});
const [signupSuccessMessage, setSignupSuccessMessage] = useState<string | null>(null);
@@ -47,12 +47,14 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
try {
setIsSignupSubmitting(true);
await authService.signUpSaas(email.trim(), password);
setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.'));
setSignupSuccessMessage(
t("signup.checkEmailConfirmation", "Check your email for a confirmation link to complete your registration."),
);
setSignupFieldErrors({});
setValidationError(null);
} catch (err) {
setSignupSuccessMessage(null);
const message = err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' });
const message = err instanceof Error ? err.message : t("signup.unexpectedError", { message: "Unknown error" });
setValidationError(message);
} finally {
setIsSignupSubmitting(false);
@@ -61,10 +63,7 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
return (
<>
<LoginHeader
title={t('signup.title', 'Create an account')}
subtitle={t('signup.subtitle', 'Join Stirling PDF')}
/>
<LoginHeader title={t("signup.title", "Create an account")} subtitle={t("signup.subtitle", "Join Stirling PDF")} />
<ErrorMessage error={displayError} />
{signupSuccessMessage && (
@@ -98,7 +97,6 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
showName={false}
showTerms={false}
/>
</>
);
};
@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import '@app/routes/authShared/auth.css';
import React from "react";
import { useTranslation } from "react-i18next";
import "@app/routes/authShared/auth.css";
interface SelfHostedLinkProps {
onClick: () => void;
@@ -11,14 +11,9 @@ export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({ onClick, disable
const { t } = useTranslation();
return (
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
<button
type="button"
onClick={onClick}
disabled={disabled}
className="navigation-link-button"
>
{t('setup.selfhosted.link', 'or connect to a self hosted account')}
<div className="navigation-link-container" style={{ marginTop: "1.5rem" }}>
<button type="button" onClick={onClick} disabled={disabled} className="navigation-link-button">
{t("setup.selfhosted.link", "or connect to a self hosted account")}
</button>
</div>
);
@@ -1,14 +1,14 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@mantine/core';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
import DividerWithText from '@app/components/shared/DividerWithText';
import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons';
import { UserInfo } from '@app/services/authService';
import { SSOProviderConfig } from '@app/services/connectionModeService';
import '@app/routes/authShared/auth.css';
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import DividerWithText from "@app/components/shared/DividerWithText";
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
import { UserInfo } from "@app/services/authService";
import { SSOProviderConfig } from "@app/services/connectionModeService";
import "@app/routes/authShared/auth.css";
interface SelfHostedLoginScreenProps {
serverUrl: string;
@@ -26,7 +26,7 @@ interface SelfHostedLoginScreenProps {
export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
serverUrl,
enabledOAuthProviders,
loginMethod = 'all',
loginMethod = "all",
onLogin,
onOAuthSuccess,
mfaCode,
@@ -36,35 +36,35 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
error,
}) => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
// Check if username/password authentication is allowed
const isUserPassAllowed = loginMethod === 'all' || loginMethod === 'normal';
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
console.log('[SelfHostedLoginScreen] Props:', {
console.log("[SelfHostedLoginScreen] Props:", {
serverUrl,
enabledOAuthProviders,
loginMethod,
isUserPassAllowed,
shouldShowOAuth: !!(enabledOAuthProviders && enabledOAuthProviders.length > 0)
shouldShowOAuth: !!(enabledOAuthProviders && enabledOAuthProviders.length > 0),
});
const handleSubmit = async () => {
// Validation
if (!username.trim()) {
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
setValidationError(t("setup.login.error.emptyUsername", "Please enter your username"));
return;
}
if (!password) {
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
setValidationError(t("setup.login.error.emptyPassword", "Please enter your password"));
return;
}
if (requiresMfa && !mfaCode.trim()) {
setValidationError(t('login.mfaRequired', 'Two-factor code required'));
setValidationError(t("login.mfaRequired", "Two-factor code required"));
return;
}
@@ -81,14 +81,17 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
return (
<>
<LoginHeader
title={t('setup.selfhosted.title', 'Sign in to Server')}
subtitle={isUserPassAllowed ? t('setup.selfhosted.subtitle', 'Enter your server credentials') : undefined}
title={t("setup.selfhosted.title", "Sign in to Server")}
subtitle={isUserPassAllowed ? t("setup.selfhosted.subtitle", "Enter your server credentials") : undefined}
/>
<ErrorMessage error={displayError} />
<Text size="sm" mb="md">
{t('setup.login.connectingTo', 'Connecting to:')} <Text span fw="500">{serverUrl}</Text>
{t("setup.login.connectingTo", "Connecting to:")}{" "}
<Text span fw="500">
{serverUrl}
</Text>
</Text>
{/* Show OAuth buttons if providers are available */}
@@ -106,7 +109,7 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
{/* Only show divider if username/password auth is also allowed */}
{isUserPassAllowed && (
<DividerWithText
text={t('setup.login.orContinueWith', 'Or continue with email')}
text={t("setup.login.orContinueWith", "Or continue with email")}
respondsToDarkMode={false}
opacity={0.4}
/>
@@ -136,7 +139,7 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
requiresMfa={requiresMfa}
onSubmit={handleSubmit}
isSubmitting={loading}
submitButtonText={t('setup.login.submit', 'Login')}
submitButtonText={t("setup.login.submit", "Login")}
/>
)}
</>
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { Stack, Button, TextInput, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ServerConfig, SSOProviderConfig } from '@app/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
import LocalIcon from '@app/components/shared/LocalIcon';
import React, { useState } from "react";
import { Stack, Button, TextInput, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ServerConfig, SSOProviderConfig } from "@app/services/connectionModeService";
import { connectionModeService } from "@app/services/connectionModeService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface ServerSelectionProps {
onSelect: (config: ServerConfig) => void;
@@ -12,41 +12,43 @@ interface ServerSelectionProps {
export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, loading }) => {
const { t } = useTranslation();
const [customUrl, setCustomUrl] = useState('');
const [customUrl, setCustomUrl] = useState("");
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [securityDisabled, setSecurityDisabled] = useState(false);
const serverUrl = localStorage.getItem('server_url') || '';
const serverUrl = localStorage.getItem("server_url") || "";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Normalize and validate URL
let url = customUrl.trim().replace(/\/+$/, '') || serverUrl;
let url = customUrl.trim().replace(/\/+$/, "") || serverUrl;
if (!url) {
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
setTestError(t("setup.server.error.emptyUrl", "Please enter a server URL"));
return;
}
// Auto-add https:// if no protocol specified
if (!url.startsWith('http://') && !url.startsWith('https://')) {
console.log('[ServerSelection] No protocol specified, adding https://');
if (!url.startsWith("http://") && !url.startsWith("https://")) {
console.log("[ServerSelection] No protocol specified, adding https://");
url = `https://${url}`;
setCustomUrl(url); // Update the input field
}
// Validate URL format
try {
const urlObj = new URL(url);
console.log('[ServerSelection] Valid URL:', {
console.log("[ServerSelection] Valid URL:", {
protocol: urlObj.protocol,
hostname: urlObj.hostname,
port: urlObj.port,
pathname: urlObj.pathname,
});
} catch (err) {
console.error('[ServerSelection] Invalid URL format:', err);
setTestError(t('setup.server.error.invalidUrl', 'Invalid URL format. Please enter a valid URL like https://your-server.com'));
console.error("[ServerSelection] Invalid URL format:", err);
setTestError(
t("setup.server.error.invalidUrl", "Invalid URL format. Please enter a valid URL like https://your-server.com"),
);
return;
}
@@ -61,19 +63,19 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
const testResult = await connectionModeService.testConnection(url);
if (!testResult.success) {
console.error('[ServerSelection] Connection test failed:', testResult);
setTestError(testResult.error || t('setup.server.error.unreachable', 'Could not connect to server'));
console.error("[ServerSelection] Connection test failed:", testResult);
setTestError(testResult.error || t("setup.server.error.unreachable", "Could not connect to server"));
setTesting(false);
return;
}
console.log('[ServerSelection] ✅ Connection test successful');
console.log("[ServerSelection] ✅ Connection test successful");
// Fetch OAuth providers and check if login is enabled
const enabledProviders: SSOProviderConfig[] = [];
let loginMethod = 'all'; // Default to 'all' (allows both SSO and username/password)
let loginMethod = "all"; // Default to 'all' (allows both SSO and username/password)
try {
console.log('[ServerSelection] Fetching login configuration...');
console.log("[ServerSelection] Fetching login configuration...");
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
// Check if security is disabled (status 403, 401, or 404 - endpoint doesn't exist)
@@ -81,96 +83,92 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
console.warn(`[ServerSelection] Login config request failed with status ${response.status}`);
if (response.status === 403 || response.status === 401 || response.status === 404) {
console.log('[ServerSelection] Security/SSO not configured on this server (or endpoint does not exist)');
console.log("[ServerSelection] Security/SSO not configured on this server (or endpoint does not exist)");
setSecurityDisabled(true);
setTesting(false);
return;
}
// Other error statuses - show generic error
setTestError(
t('setup.server.error.configFetch', 'Failed to fetch server configuration (status {{status}})', {
status: response.status
})
t("setup.server.error.configFetch", "Failed to fetch server configuration (status {{status}})", {
status: response.status,
}),
);
setTesting(false);
return;
}
const data = await response.json();
console.log('[ServerSelection] Login UI data:', data);
console.log("[ServerSelection] Login UI data:", data);
// Check if the response indicates security is disabled
if (data.enableLogin === false || data.securityEnabled === false) {
console.log('[ServerSelection] Security is explicitly disabled in config');
console.log("[ServerSelection] Security is explicitly disabled in config");
setSecurityDisabled(true);
setTesting(false);
return;
}
// Extract loginMethod from response
loginMethod = data.loginMethod || 'all';
console.log('[ServerSelection] Login method:', loginMethod);
loginMethod = data.loginMethod || "all";
console.log("[ServerSelection] Login method:", loginMethod);
// Extract provider IDs from authorization URLs
// Example: "/oauth2/authorization/google" → "google"
const providerEntries = Object.entries(data.providerList || {});
console.log('[ServerSelection] providerList from API:', data.providerList);
console.log("[ServerSelection] providerList from API:", data.providerList);
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
console.log('[ServerSelection] Processing provider path:', path, '→ id:', id);
const id = path.split("/").pop();
console.log("[ServerSelection] Processing provider path:", path, "→ id:", id);
if (!id) {
console.warn('[ServerSelection] Skipping provider with empty id:', path);
console.warn("[ServerSelection] Skipping provider with empty id:", path);
return;
}
enabledProviders.push({
id,
path,
label: typeof label === 'string' ? label : undefined,
label: typeof label === "string" ? label : undefined,
});
});
console.log('[ServerSelection] ✅ Detected OAuth providers:', enabledProviders);
console.log('[ServerSelection] Login method:', loginMethod);
console.log("[ServerSelection] ✅ Detected OAuth providers:", enabledProviders);
console.log("[ServerSelection] Login method:", loginMethod);
} catch (err) {
console.error('[ServerSelection] ❌ Failed to fetch login configuration:', err);
console.error("[ServerSelection] ❌ Failed to fetch login configuration:", err);
// Check if it's a security disabled error
if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) {
console.log('[ServerSelection] Security is disabled (error-based detection)');
if (err instanceof Error && (err.message.includes("403") || err.message.includes("401"))) {
console.log("[ServerSelection] Security is disabled (error-based detection)");
setSecurityDisabled(true);
setTesting(false);
return;
}
// For any other error (network, CORS, invalid JSON, etc.), show error and don't proceed
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
console.error('[ServerSelection] Configuration fetch error details:', errorMessage);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
console.error("[ServerSelection] Configuration fetch error details:", errorMessage);
setTestError(
t('setup.server.error.configFetchError', 'Failed to fetch server configuration: {{error}}', {
error: errorMessage
})
t("setup.server.error.configFetchError", "Failed to fetch server configuration: {{error}}", {
error: errorMessage,
}),
);
setTesting(false);
return;
}
// 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');
localStorage.setItem("server_url", url);
console.log("[ServerSelection] ✅ Server selection complete, proceeding to login");
onSelect({
url,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod,
});
} catch (error) {
console.error('[ServerSelection] ❌ Unexpected error during connection test:', error);
setTestError(
error instanceof Error
? error.message
: t('setup.server.error.testFailed', 'Connection test failed')
);
console.error("[ServerSelection] ❌ Unexpected error during connection test:", error);
setTestError(error instanceof Error ? error.message : t("setup.server.error.testFailed", "Connection test failed"));
} finally {
setTesting(false);
}
@@ -180,7 +178,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={t('setup.server.url.label', 'Server URL')}
label={t("setup.server.url.label", "Server URL")}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
@@ -190,10 +188,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
}}
disabled={loading || testing}
error={testError}
description={t(
'setup.server.url.description',
'Enter the full URL of your self-hosted Stirling PDF server'
)}
description={t("setup.server.url.description", "Enter the full URL of your self-hosted Stirling PDF server")}
/>
{securityDisabled && (
@@ -201,17 +196,22 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
variant="light"
color="orange"
icon={<LocalIcon icon="warning-rounded" width="1.25rem" height="1.25rem" />}
title={t('setup.server.error.securityDisabled.title', 'Login Not Enabled')}
title={t("setup.server.error.securityDisabled.title", "Login Not Enabled")}
>
<Stack gap="sm">
<Text size="sm">
{t('setup.server.error.securityDisabled.body', 'This server does not have login enabled. To connect to this server, you must enable authentication:')}
{t(
"setup.server.error.securityDisabled.body",
"This server does not have login enabled. To connect to this server, you must enable authentication:",
)}
</Text>
<Text size="sm" component="div">
<ol style={{ margin: 0, paddingLeft: '1.5rem' }}>
<li>{t('setup.server.error.securityDisabled.step1', 'Set DOCKER_ENABLE_SECURITY=true in your environment')}</li>
<li>{t('setup.server.error.securityDisabled.step2', 'Or set security.enableLogin=true in settings.yml')}</li>
<li>{t('setup.server.error.securityDisabled.step3', 'Restart the server')}</li>
<ol style={{ margin: 0, paddingLeft: "1.5rem" }}>
<li>
{t("setup.server.error.securityDisabled.step1", "Set DOCKER_ENABLE_SECURITY=true in your environment")}
</li>
<li>{t("setup.server.error.securityDisabled.step2", "Or set security.enableLogin=true in settings.yml")}</li>
<li>{t("setup.server.error.securityDisabled.step3", "Restart the server")}</li>
</ol>
</Text>
</Stack>
@@ -228,26 +228,17 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
setCustomUrl(serverUrl);
// Auto-submit the form after setting the URL
setTimeout(() => {
handleSubmit(new Event('submit') as any);
handleSubmit(new Event("submit") as any);
}, 0);
}}
>
{t('setup.server.useLast', 'Last used server: {{serverUrl}}', { serverUrl: serverUrl })}
{t("setup.server.useLast", "Last used server: {{serverUrl}}", { serverUrl: serverUrl })}
</button>
</div>
)}
<Button
type="submit"
loading={testing || loading}
disabled={loading}
mt="md"
fullWidth
color="#AF3434"
>
{testing
? t('setup.server.testing', 'Testing connection...')
: t('common.continue', 'Continue')}
<Button type="submit" loading={testing || loading} disabled={loading} mt="md" fullWidth color="#AF3434">
{testing ? t("setup.server.testing", "Testing connection...") : t("common.continue", "Continue")}
</Button>
</Stack>
</form>
@@ -1,10 +1,10 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
import { ServerConfig } from '@app/services/connectionModeService';
import '@app/routes/authShared/auth.css';
import React from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import { ServerSelection } from "@app/components/SetupWizard/ServerSelection";
import { ServerConfig } from "@app/services/connectionModeService";
import "@app/routes/authShared/auth.css";
interface ServerSelectionScreenProps {
onSelect: (config: ServerConfig) => void;
@@ -12,18 +12,14 @@ interface ServerSelectionScreenProps {
error: string | null;
}
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({
onSelect,
loading,
error,
}) => {
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({ onSelect, loading, error }) => {
const { t } = useTranslation();
return (
<>
<LoginHeader
title={t('setup.server.title', 'Connect to Server')}
subtitle={t('setup.server.subtitle', 'Enter your self-hosted server URL')}
title={t("setup.server.title", "Connect to Server")}
subtitle={t("setup.server.subtitle", "Enter your self-hosted server URL")}
/>
<ErrorMessage error={error} />
@@ -1,18 +1,18 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Button, Alert, Loader, Center } from '@mantine/core';
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
import { ServerConfig, SSOProviderConfig, connectionModeService } from '@app/services/connectionModeService';
import { AuthServiceError, authService, UserInfo } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { listen } from '@tauri-apps/api/event';
import '@app/routes/authShared/auth.css';
import { DisabledButtonWithTooltip } from '@app/components/shared/DisabledButtonWithTooltip';
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Text, Button, Alert, Loader, Center } from "@mantine/core";
import { DesktopAuthLayout } from "@app/components/SetupWizard/DesktopAuthLayout";
import { SaaSLoginScreen } from "@app/components/SetupWizard/SaaSLoginScreen";
import { SaaSSignupScreen } from "@app/components/SetupWizard/SaaSSignupScreen";
import { ServerSelectionScreen } from "@app/components/SetupWizard/ServerSelectionScreen";
import { SelfHostedLoginScreen } from "@app/components/SetupWizard/SelfHostedLoginScreen";
import { ServerConfig, SSOProviderConfig, connectionModeService } from "@app/services/connectionModeService";
import { AuthServiceError, authService, UserInfo } from "@app/services/authService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { listen } from "@tauri-apps/api/event";
import "@app/routes/authShared/auth.css";
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
enum SetupStep {
SaaSLogin,
@@ -29,14 +29,13 @@ interface SetupWizardProps {
onClose?: () => void;
}
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout = false, onClose }) => {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState("");
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
const [lockConnectionMode, setLockConnectionMode] = useState(false);
const [lockedServerUnreachable, setLockedServerUnreachable] = useState(false);
@@ -44,7 +43,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No SaaS server configured');
setError("No SaaS server configured");
return;
}
@@ -63,15 +62,15 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error('SaaS login failed:', err);
setError(err instanceof Error ? err.message : 'SaaS login failed');
console.error("SaaS login failed:", err);
setError(err instanceof Error ? err.message : "SaaS login failed");
setLoading(false);
}
};
const handleSaaSLoginOAuth = async (_userInfo: UserInfo) => {
if (!serverConfig) {
setError('No SaaS server configured');
setError("No SaaS server configured");
return;
}
@@ -84,8 +83,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error('SaaS OAuth login completion failed:', err);
setError(err instanceof Error ? err.message : 'Failed to complete SaaS login');
console.error("SaaS OAuth login completion failed:", err);
setError(err instanceof Error ? err.message : "Failed to complete SaaS login");
setLoading(false);
}
};
@@ -96,13 +95,13 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setError(null);
// Save the server URL so it pre-fills on reconnect
if (serverConfig?.url) {
localStorage.setItem('server_url', 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);
console.error("Failed to continue in local mode:", err);
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
@@ -127,24 +126,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
};
const handleServerSelection = (config: ServerConfig) => {
console.log('[SetupWizard] Server selected:', config);
console.log('[SetupWizard] OAuth providers:', config.enabledOAuthProviders);
console.log('[SetupWizard] Login method:', config.loginMethod);
console.log("[SetupWizard] Server selected:", config);
console.log("[SetupWizard] OAuth providers:", config.enabledOAuthProviders);
console.log("[SetupWizard] Login method:", config.loginMethod);
setServerConfig(config);
setError(null);
setSelfHostedMfaCode('');
setSelfHostedMfaCode("");
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.SelfHostedLogin);
};
const handleSelfHostedLogin = async (username: string, password: string) => {
console.log('[SetupWizard] 🔐 Starting self-hosted login');
console.log("[SetupWizard] 🔐 Starting self-hosted login");
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
console.log(`[SetupWizard] Username: ${username}`);
if (!serverConfig) {
console.error('[SetupWizard] ❌ No server configured');
setError('No server configured');
console.error("[SetupWizard] ❌ No server configured");
setError("No server configured");
return;
}
@@ -152,54 +151,54 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setLoading(true);
setError(null);
console.log('[SetupWizard] Step 1: Authenticating with server...');
console.log("[SetupWizard] Step 1: Authenticating with server...");
const trimmedMfa = selfHostedMfaCode.trim();
const mfaCode = trimmedMfa ? trimmedMfa : undefined;
await authService.login(serverConfig.url, username, password, mfaCode);
console.log('[SetupWizard] ✅ Authentication successful');
console.log("[SetupWizard] ✅ Authentication successful");
setSelfHostedMfaRequired(false);
setSelfHostedMfaCode('');
setSelfHostedMfaCode("");
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
console.log("[SetupWizard] Step 2: Switching to self-hosted mode...");
await connectionModeService.switchToSelfHosted(serverConfig);
console.log('[SetupWizard] ✅ Switched to self-hosted mode');
console.log("[SetupWizard] ✅ Switched to self-hosted mode");
console.log('[SetupWizard] Step 3: Initializing external backend...');
console.log("[SetupWizard] Step 3: Initializing external backend...");
await tauriBackendService.initializeExternalBackend();
console.log('[SetupWizard] ✅ External backend initialized');
console.log("[SetupWizard] ✅ External backend initialized");
console.log('[SetupWizard] ✅ Setup complete, calling onComplete()');
console.log("[SetupWizard] ✅ Setup complete, calling onComplete()");
onComplete();
} catch (err) {
console.error('[SetupWizard] ❌ Self-hosted login failed:', err);
let errorMessage = 'Self-hosted login failed';
console.error("[SetupWizard] ❌ Self-hosted login failed:", err);
let errorMessage = "Self-hosted login failed";
if (err instanceof AuthServiceError) {
if (err.code === 'mfa_required' || err.code === 'invalid_mfa_code') {
if (err.code === "mfa_required" || err.code === "invalid_mfa_code") {
setSelfHostedMfaRequired(true);
}
errorMessage = err.message;
} else if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
} else if (typeof err === "string") {
errorMessage = err;
}
if (errorMessage.toLowerCase().includes('mfa_required') || errorMessage.toLowerCase().includes('invalid_mfa_code')) {
if (errorMessage.toLowerCase().includes("mfa_required") || errorMessage.toLowerCase().includes("invalid_mfa_code")) {
setSelfHostedMfaRequired(true);
}
console.error('[SetupWizard] Error message:', errorMessage);
console.error("[SetupWizard] Error message:", errorMessage);
setError(errorMessage);
setLoading(false);
}
};
const handleSelfHostedOAuthSuccess = async (_userInfo: UserInfo) => {
console.log('[SetupWizard] 🔐 OAuth login successful, completing setup');
console.log("[SetupWizard] 🔐 OAuth login successful, completing setup");
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
if (!serverConfig) {
console.error('[SetupWizard] ❌ No server configured');
setError('No server configured');
console.error("[SetupWizard] ❌ No server configured");
setError("No server configured");
return;
}
@@ -207,28 +206,28 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setLoading(true);
setError(null);
console.log('[SetupWizard] Step 1: OAuth already completed');
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
console.log("[SetupWizard] Step 1: OAuth already completed");
console.log("[SetupWizard] Step 2: Switching to self-hosted mode...");
await connectionModeService.switchToSelfHosted(serverConfig);
console.log('[SetupWizard] ✅ Switched to self-hosted mode');
console.log("[SetupWizard] ✅ Switched to self-hosted mode");
console.log('[SetupWizard] Step 3: Initializing external backend...');
console.log("[SetupWizard] Step 3: Initializing external backend...");
await tauriBackendService.initializeExternalBackend();
console.log('[SetupWizard] ✅ External backend initialized');
console.log("[SetupWizard] ✅ External backend initialized");
console.log('[SetupWizard] ✅ Setup complete, calling onComplete()');
console.log("[SetupWizard] ✅ Setup complete, calling onComplete()");
onComplete();
} catch (err) {
console.error('[SetupWizard] ❌ Self-hosted OAuth login completion failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to complete login';
console.error('[SetupWizard] Error message:', errorMessage);
console.error("[SetupWizard] ❌ Self-hosted OAuth login completion failed:", err);
const errorMessage = err instanceof Error ? err.message : "Failed to complete login";
console.error("[SetupWizard] Error message:", errorMessage);
setError(errorMessage);
setLoading(false);
}
};
useEffect(() => {
const unsubscribePromise = listen<string>('deep-link', async (event) => {
const unsubscribePromise = listen<string>("deep-link", async (event) => {
const url = event.payload;
if (!url) return;
@@ -236,24 +235,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
const parsed = new URL(url);
// Supabase sends tokens in the URL hash
const hash = parsed.hash.replace(/^#/, '');
const hash = parsed.hash.replace(/^#/, "");
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const type = params.get('type') || parsed.searchParams.get('type');
const accessToken = params.get("access_token");
const type = params.get("type") || parsed.searchParams.get("type");
// Self-hosted SSO deep links are normally handled by authService.loginWithSelfHostedOAuth.
// Fallback here only if no in-flight auth listener exists (e.g. renderer reload mid-flow).
if (type === 'sso' || type === 'sso-selfhosted') {
if (type === "sso" || type === "sso-selfhosted") {
if (authService.isSelfHostedDeepLinkFlowActive()) {
return;
}
const accessTokenFromHash = params.get('access_token');
const accessTokenFromQuery = parsed.searchParams.get('access_token');
const serverFromQuery = parsed.searchParams.get('server');
const accessTokenFromHash = params.get("access_token");
const accessTokenFromQuery = parsed.searchParams.get("access_token");
const serverFromQuery = parsed.searchParams.get("server");
const token = accessTokenFromHash || accessTokenFromQuery;
const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
if (!token || !serverUrl) {
console.error('[SetupWizard] Deep link missing token or server for SSO completion');
console.error("[SetupWizard] Deep link missing token or server for SSO completion");
return;
}
@@ -267,12 +266,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
return;
}
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
if (!type || (type !== "signup" && type !== "recovery" && type !== "magiclink")) {
return;
}
if (!accessToken) {
console.error('[SetupWizard] Deep link missing access_token');
console.error("[SetupWizard] Deep link missing access_token");
return;
}
@@ -284,8 +283,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error('[SetupWizard] Failed to handle deep link', err);
setError(err instanceof Error ? err.message : 'Failed to complete signup');
console.error("[SetupWizard] Failed to handle deep link", err);
setError(err instanceof Error ? err.message : "Failed to complete signup");
setLoading(false);
}
});
@@ -301,7 +300,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
}
setError(null);
if (activeStep === SetupStep.SelfHostedLogin) {
setSelfHostedMfaCode('');
setSelfHostedMfaCode("");
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
@@ -322,7 +321,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setLockedServerUnreachable(false);
setLockedServerChecking(true);
const savedUrl = serverUrl.replace(/\/+$/, '');
const savedUrl = serverUrl.replace(/\/+$/, "");
let updatedConfig: ServerConfig = { ...(currentConfig.server_config ?? { url: savedUrl }) };
try {
@@ -334,12 +333,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
const providerEntries = Object.entries(data.providerList || {});
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
const id = path.split("/").pop();
if (id) {
enabledProviders.push({
id,
path,
label: typeof label === 'string' ? label : undefined,
label: typeof label === "string" ? label : undefined,
});
}
});
@@ -347,7 +346,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
updatedConfig = {
...updatedConfig,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod: data.loginMethod || 'all',
loginMethod: data.loginMethod || "all",
};
setServerConfig(updatedConfig);
@@ -355,14 +354,14 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setActiveStep(SetupStep.SelfHostedLogin);
} else {
// Server responded but with an error — still show login form
updatedConfig = { ...updatedConfig, loginMethod: 'all' };
updatedConfig = { ...updatedConfig, loginMethod: "all" };
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setActiveStep(SetupStep.SelfHostedLogin);
}
} catch (err) {
// Network error — server is unreachable
console.error('[SetupWizard] Server unreachable:', err);
console.error("[SetupWizard] Server unreachable:", err);
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setLockedServerUnreachable(true);
@@ -392,20 +391,11 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
)}
{!lockConnectionMode && activeStep === SetupStep.SaaSSignup && (
<SaaSSignupScreen
loading={loading}
error={error}
onLogin={handleSaaSLogin}
onSwitchToLogin={handleSwitchToLogin}
/>
<SaaSSignupScreen loading={loading} error={error} onLogin={handleSaaSLogin} onSwitchToLogin={handleSwitchToLogin} />
)}
{!lockConnectionMode && activeStep === SetupStep.ServerSelection && (
<ServerSelectionScreen
onSelect={handleServerSelection}
loading={loading}
error={error}
/>
<ServerSelectionScreen onSelect={handleServerSelection} loading={loading} error={error} />
)}
{lockConnectionMode && lockedServerChecking && (
@@ -415,28 +405,29 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
)}
{activeStep === SetupStep.SelfHostedLogin && lockedServerUnreachable && !lockedServerChecking && (
<Stack gap="md" style={{ padding: '0.5rem 0' }}>
<Alert color="orange" title={t('setup.selfhosted.unreachable.title', 'Cannot connect to server')}>
<Stack gap="md" style={{ padding: "0.5rem 0" }}>
<Alert color="orange" title={t("setup.selfhosted.unreachable.title", "Cannot connect to server")}>
<Text size="sm">
{t('setup.selfhosted.unreachable.message', 'Could not reach {{url}}. Check that the server is running and accessible.', {
url: serverConfig?.url,
})}
{t(
"setup.selfhosted.unreachable.message",
"Could not reach {{url}}. Check that the server is running and accessible.",
{
url: serverConfig?.url,
},
)}
</Text>
</Alert>
<Button
variant="filled"
color="blue"
fullWidth
loading={loading}
onClick={() => void loadLockedConfig()}
>
{t('setup.selfhosted.unreachable.retry', 'Retry')}
<Button variant="filled" color="blue" fullWidth loading={loading} onClick={() => void loadLockedConfig()}>
{t("setup.selfhosted.unreachable.retry", "Retry")}
</Button>
{lockConnectionMode ? (
<DisabledButtonWithTooltip
tooltip={t('setup.selfhosted.changeServerLocked', 'Your organisation has restricted this app to a specific server')}
tooltip={t(
"setup.selfhosted.changeServerLocked",
"Your organisation has restricted this app to a specific server",
)}
>
{t('setup.selfhosted.unreachable.changeServer', 'Connect to a different server')}
{t("setup.selfhosted.unreachable.changeServer", "Connect to a different server")}
</DisabledButtonWithTooltip>
) : (
<Button
@@ -449,16 +440,11 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setActiveStep(SetupStep.ServerSelection);
}}
>
{t('setup.selfhosted.unreachable.changeServer', 'Connect to a different server')}
{t("setup.selfhosted.unreachable.changeServer", "Connect to a different server")}
</Button>
)}
<Button
variant="subtle"
color="white"
fullWidth
onClick={handleLocalMode}
>
{t('setup.selfhosted.unreachable.continueOffline', 'Use local tools instead')}
<Button variant="subtle" color="white" fullWidth onClick={handleLocalMode}>
{t("setup.selfhosted.unreachable.continueOffline", "Use local tools instead")}
</Button>
</Stack>
)}
@@ -466,7 +452,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
{activeStep === SetupStep.SelfHostedLogin && !lockedServerUnreachable && !lockedServerChecking && (
<>
<SelfHostedLoginScreen
serverUrl={serverConfig?.url || ''}
serverUrl={serverConfig?.url || ""}
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
loginMethod={serverConfig?.loginMethod}
onLogin={handleSelfHostedLogin}
@@ -477,14 +463,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
loading={loading}
error={error}
/>
<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')}
<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>
</>
@@ -492,13 +473,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
{/* Back Button */}
{!lockConnectionMode && activeStep > SetupStep.SaaSLogin && !loading && (
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
<button
type="button"
onClick={handleBack}
className="navigation-link-button"
>
{t('common.back', 'Back')}
<div className="navigation-link-container" style={{ marginTop: "1.5rem" }}>
<button type="button" onClick={handleBack} className="navigation-link-button">
{t("common.back", "Back")}
</button>
</div>
)}
@@ -506,12 +483,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
);
if (noLayout) {
return <div style={{ padding: '2rem' }}>{wizardContent}</div>;
return <div style={{ padding: "2rem" }}>{wizardContent}</div>;
}
return (
<DesktopAuthLayout>
{wizardContent}
</DesktopAuthLayout>
);
return <DesktopAuthLayout>{wizardContent}</DesktopAuthLayout>;
};
@@ -1,8 +1,8 @@
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';
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);
@@ -23,7 +23,9 @@ export function SignInModal() {
return (
<Modal
opened={opened}
onClose={() => { if (!locked) setOpened(false); }}
onClose={() => {
if (!locked) setOpened(false);
}}
size={520}
centered
withCloseButton={false}
@@ -1,8 +1,8 @@
import React from 'react';
import { Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React from "react";
import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface FileEditorFileNameProps {
file: StirlingFileStub;
@@ -15,47 +15,47 @@ const FileEditorFileName = ({ file }: FileEditorFileNameProps) => {
<>
<PrivateContent>{file.name}</PrivateContent>
{!file.localFilePath && (
<Tooltip label={t('fileNotSavedToDisk', 'Not saved to disk')}>
<Tooltip label={t("fileNotSavedToDisk", "Not saved to disk")}>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-red-6)',
flexShrink: 0
display: "inline-block",
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: "var(--mantine-color-red-6)",
flexShrink: 0,
}}
aria-label={t('fileNotSavedToDisk', 'Not saved to disk')}
aria-label={t("fileNotSavedToDisk", "Not saved to disk")}
/>
</Tooltip>
)}
{file.localFilePath && file.isDirty && (
<Tooltip label={t('unsavedChanges', 'Unsaved changes')}>
<Tooltip label={t("unsavedChanges", "Unsaved changes")}>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-yellow-6)',
flexShrink: 0
display: "inline-block",
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: "var(--mantine-color-yellow-6)",
flexShrink: 0,
}}
aria-label={t('unsavedChanges', 'Unsaved changes')}
aria-label={t("unsavedChanges", "Unsaved changes")}
/>
</Tooltip>
)}
{file.localFilePath && !file.isDirty && (
<Tooltip label={t('fileSavedToDisk', 'File saved to disk')}>
<Tooltip label={t("fileSavedToDisk", "File saved to disk")}>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-green-6)',
flexShrink: 0
display: "inline-block",
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: "var(--mantine-color-green-6)",
flexShrink: 0,
}}
aria-label={t('fileSavedToDisk', 'File saved to disk')}
aria-label={t("fileSavedToDisk", "File saved to disk")}
/>
</Tooltip>
)}
@@ -1,19 +1,15 @@
/**
* Desktop Override: Onboarding Configuration
*
*
* This version modifies the onboarding config for the desktop app:
* - Sets isDesktopApp to true in the default runtime state
* - This causes desktop-install step to be skipped
*
*
* All other step definitions and logic remain the same.
*/
// Re-export everything from core
export {
ONBOARDING_STEPS,
getStepById,
getStepIndex,
} from '@core/components/onboarding/orchestrator/onboardingConfig';
export { ONBOARDING_STEPS, getStepById, getStepIndex } from "@core/components/onboarding/orchestrator/onboardingConfig";
export type {
OnboardingStepId,
@@ -21,11 +17,11 @@ export type {
OnboardingStep,
OnboardingRuntimeState,
OnboardingConditionContext,
} from '@core/components/onboarding/orchestrator/onboardingConfig';
} from "@core/components/onboarding/orchestrator/onboardingConfig";
// Import and override the default runtime state
import { DEFAULT_RUNTIME_STATE as CORE_DEFAULT_RUNTIME_STATE } from '@core/components/onboarding/orchestrator/onboardingConfig';
import type { OnboardingRuntimeState } from '@core/components/onboarding/orchestrator/onboardingConfig';
import { DEFAULT_RUNTIME_STATE as CORE_DEFAULT_RUNTIME_STATE } from "@core/components/onboarding/orchestrator/onboardingConfig";
import type { OnboardingRuntimeState } from "@core/components/onboarding/orchestrator/onboardingConfig";
/**
* Desktop default runtime state
@@ -35,4 +31,3 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
...CORE_DEFAULT_RUNTIME_STATE,
isDesktopApp: true,
};
@@ -8,14 +8,14 @@
import {
useOnboardingOrchestrator as useCoreOnboardingOrchestrator,
type UseOnboardingOrchestratorResult,
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { DEFAULT_RUNTIME_STATE } from '@app/components/onboarding/orchestrator/onboardingConfig';
} from "@core/components/onboarding/orchestrator/useOnboardingOrchestrator";
import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig";
export type {
OnboardingOrchestratorState,
OnboardingOrchestratorActions,
UseOnboardingOrchestratorResult,
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
} from "@core/components/onboarding/orchestrator/useOnboardingOrchestrator";
export function useOnboardingOrchestrator(): UseOnboardingOrchestratorResult {
return useCoreOnboardingOrchestrator({ defaultRuntimeState: DEFAULT_RUNTIME_STATE });
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { Box, Text, Stack } from '@mantine/core';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { BILLING_CONFIG } from '@app/config/billing';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
import { useEffect, useState } from "react";
import { Box, Text, Stack } from "@mantine/core";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { BILLING_CONFIG } from "@app/config/billing";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
/**
* Desktop credit counter displayed in QuickAccessBar footer
@@ -25,7 +25,7 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === 'saas');
setIsSaasMode(mode === "saas");
setIsAuthenticated(auth);
};
@@ -39,7 +39,7 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
@@ -50,24 +50,32 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
// - Still loading billing data
// - User is a managed team member (unlimited credits)
// - Credits >= 20 (only show when low)
if (!isSaasMode || !isAuthenticated || loading || isManagedTeamMember || creditBalance >= BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD) {
if (
!isSaasMode ||
!isAuthenticated ||
loading ||
isManagedTeamMember ||
creditBalance >= BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD
) {
return null;
}
const handleClick = () => {
// Dispatch low credits event to open upgrade modal
window.dispatchEvent(new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: { source: 'quickAccessBar' }
}));
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: { source: "quickAccessBar" },
}),
);
};
return (
<Box className={className} style={{ padding: '0.5rem', cursor: 'pointer' }} onClick={handleClick}>
<Box className={className} style={{ padding: "0.5rem", cursor: "pointer" }} onClick={handleClick}>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed" fw={500}>
{creditBalance} {creditBalance === 1 ? 'credit' : 'credits'}
{creditBalance} {creditBalance === 1 ? "credit" : "credits"}
</Text>
<Text size="xs" c="dimmed" style={{ textDecoration: 'underline' }}>
<Text size="xs" c="dimmed" style={{ textDecoration: "underline" }}>
Upgrade
</Text>
</Stack>
@@ -1,10 +1,10 @@
import { useState, useEffect, useMemo } from 'react';
import { Box, Tooltip, rem, useComputedColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
import { useState, useEffect, useMemo } from "react";
import { Box, Tooltip, rem, useComputedColorScheme } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { connectionModeService, type ConnectionMode } from "@app/services/connectionModeService";
import { selfHostedServerMonitor, type SelfHostedServerState } from "@app/services/selfHostedServerMonitor";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
interface RightRailFooterExtensionsProps {
className?: string;
@@ -12,11 +12,9 @@ interface RightRailFooterExtensionsProps {
function ConnectionStatusDot() {
const { t } = useTranslation();
const colorScheme = useComputedColorScheme('light');
const colorScheme = useComputedColorScheme("light");
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(
() => selfHostedServerMonitor.getSnapshot()
);
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(() => selfHostedServerMonitor.getSnapshot());
const { isOnline, checkHealth } = useBackendHealth();
useEffect(() => {
@@ -32,31 +30,31 @@ function ConnectionStatusDot() {
}, []);
const { label, color } = useMemo(() => {
if (connectionMode === 'saas') {
if (connectionMode === "saas") {
return {
label: t('connectionMode.status.saas', 'Connected to Stirling Cloud'),
color: '#3b82f6',
label: t("connectionMode.status.saas", "Connected to Stirling Cloud"),
color: "#3b82f6",
};
}
if (connectionMode === 'selfhosted') {
if (connectionMode === "selfhosted") {
const serverOnline = selfHostedState.isOnline;
const serverChecking = selfHostedState.status === 'checking';
const serverChecking = selfHostedState.status === "checking";
const backendLabel = serverChecking
? t('connectionMode.status.selfhostedChecking', 'Connected to self-hosted server (checking...)')
? 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');
? 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',
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',
? t("connectionMode.status.localOnline", "Offline mode running")
: t("connectionMode.status.localOffline", "Offline mode running"),
color: "#868e96",
};
}, [connectionMode, selfHostedState, isOnline, t]);
@@ -67,7 +65,7 @@ function ConnectionStatusDot() {
offset={12}
withArrow
withinPortal
color={colorScheme === 'dark' ? undefined : 'dark'}
color={colorScheme === "dark" ? undefined : "dark"}
>
<Box
component="span"
@@ -75,7 +73,7 @@ function ConnectionStatusDot() {
aria-label={label}
tabIndex={0}
onClick={() => {
if (connectionMode === 'local') {
if (connectionMode === "local") {
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
} else {
void checkHealth();
@@ -84,14 +82,12 @@ function ConnectionStatusDot() {
style={{
width: rem(10),
height: rem(10),
borderRadius: '50%',
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',
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>
@@ -103,10 +99,10 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
<Box
className={className}
style={{
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
paddingBottom: rem(12),
}}
>
@@ -1,6 +1,6 @@
import { Badge, Tooltip } from '@mantine/core';
import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined';
import { useTranslation } from 'react-i18next';
import { Badge, Tooltip } from "@mantine/core";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import { useTranslation } from "react-i18next";
interface CloudBadgeProps {
className?: string;
@@ -15,19 +15,14 @@ export function CloudBadge({ className }: CloudBadgeProps) {
const { t } = useTranslation();
return (
<Tooltip
label={t('cloudBadge.tooltip', 'This operation will use your cloud credits')}
position="top"
withArrow
>
<Tooltip label={t("cloudBadge.tooltip", "This operation will use your cloud credits")} position="top" withArrow>
<Badge
className={className}
leftSection={<CloudOutlinedIcon sx={{ fontSize: 12 }} />}
variant="light"
color="blue"
size="xs"
>
</Badge>
></Badge>
</Tooltip>
);
}
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppBanner: React.FC = () => {
const { t } = useTranslation();
@@ -15,8 +15,8 @@ export const DefaultAppBanner: React.FC = () => {
return (
<InfoBanner
icon="picture-as-pdf-rounded"
message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
buttonText={t('defaultApp.setDefault', 'Set Default')}
message={t("defaultApp.prompt.message", "Make Stirling PDF your default application for opening PDF files.")}
buttonText={t("defaultApp.setDefault", "Set Default")}
buttonIcon="check-circle-rounded"
onButtonClick={handleSetDefault}
onDismiss={handleDismissPrompt}
@@ -1,5 +1,5 @@
import React from 'react';
import '@app/components/shared/DisabledButtonWithTooltip.css';
import React from "react";
import "@app/components/shared/DisabledButtonWithTooltip.css";
interface DisabledButtonWithTooltipProps {
/** Tooltip text shown on hover */
@@ -17,12 +17,8 @@ interface DisabledButtonWithTooltipProps {
export function DisabledButtonWithTooltip({ tooltip, children, className, style }: DisabledButtonWithTooltipProps) {
const [hovered, setHovered] = React.useState(false);
return (
<div
className="relative w-full"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className={`locked-button${className ? ` ${className}` : ''}`} style={style}>
<div className="relative w-full" onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>
<div className={`locked-button${className ? ` ${className}` : ""}`} style={style}>
{children}
</div>
{hovered && (
@@ -1,35 +1,33 @@
import { useState, useEffect, useMemo } from 'react';
import { Paper, Group, Text, ActionIcon, UnstyledButton, Popover, List, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useConversionCloudStatus } from '@app/hooks/useConversionCloudStatus';
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { EXTENSION_TO_ENDPOINT, ENDPOINT_I18N } from '@app/constants/convertConstants';
import { ENDPOINTS as SPLIT_ENDPOINTS } from '@app/constants/splitConstants';
import type { ToolId } from '@app/types/toolId';
import { useState, useEffect, useMemo } from "react";
import { Paper, Group, Text, ActionIcon, UnstyledButton, Popover, List, ScrollArea } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus";
import { selfHostedServerMonitor, type SelfHostedServerState } from "@app/services/selfHostedServerMonitor";
import { connectionModeService, type ConnectionMode } from "@app/services/connectionModeService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { EXTENSION_TO_ENDPOINT, ENDPOINT_I18N } from "@app/constants/convertConstants";
import { ENDPOINTS as SPLIT_ENDPOINTS } from "@app/constants/splitConstants";
import type { ToolId } from "@app/types/toolId";
const BANNER_BG = 'var(--mantine-color-gray-1)';
const BANNER_BORDER = 'var(--mantine-color-gray-3)';
const BANNER_TEXT = 'var(--mantine-color-gray-7)';
const BANNER_ICON = 'var(--mantine-color-gray-5)';
const BANNER_LINK = 'var(--mantine-color-gray-6)';
const BANNER_BG = "var(--mantine-color-gray-1)";
const BANNER_BORDER = "var(--mantine-color-gray-3)";
const BANNER_TEXT = "var(--mantine-color-gray-7)";
const BANNER_ICON = "var(--mantine-color-gray-5)";
const BANNER_LINK = "var(--mantine-color-gray-6)";
/** Maps split endpoint → [i18n key, English fallback] for the method name */
const SPLIT_ENDPOINT_I18N: Record<string, [string, string]> = {
'split-pages': ['split.methods.byPages.name', 'Pages'],
'split-pdf-by-sections': ['split.methods.bySections.name', 'Sections'],
'split-by-size-or-count': ['split.methods.bySize.name', 'File Size'],
'split-pdf-by-chapters': ['split.methods.byChapters.name', 'Chapters'],
'auto-split-pdf': ['split.methods.byPageDivider.name', 'Page Divider'],
'split-for-poster-print': ['split.methods.byPoster.name', 'Printable Chunks'],
"split-pages": ["split.methods.byPages.name", "Pages"],
"split-pdf-by-sections": ["split.methods.bySections.name", "Sections"],
"split-by-size-or-count": ["split.methods.bySize.name", "File Size"],
"split-pdf-by-chapters": ["split.methods.byChapters.name", "Chapters"],
"auto-split-pdf": ["split.methods.byPageDivider.name", "Page Divider"],
"split-for-poster-print": ["split.methods.byPoster.name", "Printable Chunks"],
};
/**
* Desktop-only banner shown when the user is in self-hosted mode and the
* configured Stirling-PDF server is unreachable.
@@ -42,24 +40,20 @@ const SPLIT_ENDPOINT_I18N: Record<string, [string, string]> = {
export function SelfHostedOfflineBanner() {
const { t } = useTranslation();
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
const [serverState, setServerState] = useState<SelfHostedServerState>(
() => selfHostedServerMonitor.getSnapshot()
);
const [serverState, setServerState] = useState<SelfHostedServerState>(() => selfHostedServerMonitor.getSnapshot());
const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [localBackendReady, setLocalBackendReady] = useState(
() => !!tauriBackendService.getBackendUrl()
);
const [localBackendReady, setLocalBackendReady] = useState(() => !!tauriBackendService.getBackendUrl());
// Load connection mode and keep it live via subscription
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
return connectionModeService.subscribeToModeChanges((config) => setConnectionMode(config.mode));
}, []);
// Subscribe to self-hosted server status changes
useEffect(() => {
const unsub = selfHostedServerMonitor.subscribe(state => {
const unsub = selfHostedServerMonitor.subscribe((state) => {
setServerState(state);
// Auto-collapse tool list when server comes back online
if (state.isOnline) setExpanded(false);
@@ -83,7 +77,7 @@ export function SelfHostedOfflineBanner() {
const [splitAvailability, setSplitAvailability] = useState<Record<string, boolean>>({});
useEffect(() => {
if (serverState.status !== 'offline') {
if (serverState.status !== "offline") {
setSplitAvailability({});
return;
}
@@ -94,11 +88,11 @@ export function SelfHostedOfflineBanner() {
}
const uniqueEndpoints = [...new Set(Object.values(SPLIT_ENDPOINTS))] as string[];
void Promise.all(
uniqueEndpoints.map(async ep => ({
uniqueEndpoints.map(async (ep) => ({
ep,
supported: await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl).catch(() => false),
}))
).then(results => {
})),
).then((results) => {
const map: Record<string, boolean> = {};
for (const { ep, supported } of results) map[ep] = supported;
setSplitAvailability(map);
@@ -108,19 +102,19 @@ export function SelfHostedOfflineBanner() {
const allUnavailableNames = useMemo(() => {
// Top-level tools unavailable in self-hosted offline mode
const toolNames = (Object.keys(toolAvailability) as ToolId[])
.filter(id => toolAvailability[id]?.available === false && toolAvailability[id]?.reason === 'selfHostedOffline')
.map(id => toolRegistry[id]?.name ?? id)
.filter((id) => toolAvailability[id]?.available === false && toolAvailability[id]?.reason === "selfHostedOffline")
.map((id) => toolRegistry[id]?.name ?? id)
.filter(Boolean) as string[];
// Use translated tool names from the registry as prefixes
const convertPrefix = toolRegistry['convert' as ToolId]?.name ?? 'Convert';
const splitPrefix = toolRegistry['split' as ToolId]?.name ?? 'Split';
const convertPrefix = toolRegistry["convert" as ToolId]?.name ?? "Convert";
const splitPrefix = toolRegistry["split" as ToolId]?.name ?? "Split";
// Conversion types unavailable locally — deduplicated by endpoint
const unavailableEndpoints = new Set<string>();
for (const [key, available] of Object.entries(conversionAvailability)) {
if (!available) {
const dashIdx = key.indexOf('-');
const dashIdx = key.indexOf("-");
const fromExt = key.slice(0, dashIdx);
const toExt = key.slice(dashIdx + 1);
const endpoint = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
@@ -128,11 +122,9 @@ export function SelfHostedOfflineBanner() {
}
}
const conversionNames = [...unavailableEndpoints]
.map(ep => {
.map((ep) => {
const i18n = ENDPOINT_I18N[ep];
const suffix = i18n
? (i18n[0] ? t(i18n[0], i18n[1]) : i18n[1])
: ep;
const suffix = i18n ? (i18n[0] ? t(i18n[0], i18n[1]) : i18n[1]) : ep;
return `${convertPrefix}: ${suffix}`;
})
.filter(Boolean);
@@ -151,16 +143,13 @@ export function SelfHostedOfflineBanner() {
}, [toolAvailability, toolRegistry, conversionAvailability, splitAvailability, t]);
// Only show when in self-hosted mode, server confirmed offline, and not dismissed
const show =
!dismissed &&
connectionMode === 'selfhosted' &&
serverState.status === 'offline';
const show = !dismissed && connectionMode === "selfhosted" && serverState.status === "offline";
if (!show) return null;
const messageText = localBackendReady
? t('selfHosted.offline.messageWithFallback', 'Some tools require a server connection.')
: t('selfHosted.offline.messageNoFallback', 'Tools are unavailable until your server comes back online.');
? t("selfHosted.offline.messageWithFallback", "Some tools require a server connection.")
: t("selfHosted.offline.messageNoFallback", "Tools are unavailable until your server comes back online.");
return (
<Paper
@@ -172,16 +161,14 @@ export function SelfHostedOfflineBanner() {
>
<Group gap="xs" align="center" wrap="nowrap" justify="space-between" px="sm" py={6}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<LocalIcon
icon="warning-rounded"
width="1rem"
height="1rem"
style={{ color: BANNER_ICON, flexShrink: 0 }}
/>
<LocalIcon icon="warning-rounded" width="1rem" height="1rem" style={{ color: BANNER_ICON, flexShrink: 0 }} />
<Text size="xs" fw={600} style={{ color: BANNER_TEXT, flexShrink: 0 }}>
{t('selfHosted.offline.title', 'Server unreachable')}
{t("selfHosted.offline.title", "Server unreachable")}
</Text>
<Text size="xs" style={{ color: BANNER_TEXT, opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Text
size="xs"
style={{ color: BANNER_TEXT, opacity: 0.8, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{messageText}
</Text>
</Group>
@@ -196,18 +183,24 @@ export function SelfHostedOfflineBanner() {
>
<Popover.Target>
<UnstyledButton
onClick={() => setExpanded(e => !e)}
style={{ color: BANNER_LINK, fontSize: 'var(--mantine-font-size-xs)', fontWeight: 500, flexShrink: 0, whiteSpace: 'nowrap' }}
onClick={() => setExpanded((e) => !e)}
style={{
color: BANNER_LINK,
fontSize: "var(--mantine-font-size-xs)",
fontWeight: 500,
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
{expanded
? t('selfHosted.offline.hideTools', 'Hide unavailable tools ▴')
: t('selfHosted.offline.showTools', 'View unavailable tools ▾')}
? t("selfHosted.offline.hideTools", "Hide unavailable tools ▴")
: t("selfHosted.offline.showTools", "View unavailable tools ▾")}
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p="xs">
<ScrollArea.Autosize mah={300}>
<List size="xs" spacing={2}>
{allUnavailableNames.map(name => (
{allUnavailableNames.map((name) => (
<List.Item key={name}>{name}</List.Item>
))}
</List>
@@ -219,7 +212,7 @@ export function SelfHostedOfflineBanner() {
variant="subtle"
size="xs"
onClick={() => setDismissed(true)}
aria-label={t('close', 'Close')}
aria-label={t("close", "Close")}
style={{ color: BANNER_TEXT }}
>
<LocalIcon icon="close-rounded" width="0.8rem" height="0.8rem" />
@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react';
import { Button, Group, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { connectionModeService } from '@app/services/connectionModeService';
import { useState, useEffect } from "react";
import { Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { connectionModeService } from "@app/services/connectionModeService";
export function TeamInvitationBanner() {
const { t } = useTranslation();
@@ -18,7 +18,7 @@ export function TeamInvitationBanner() {
// Load connection mode on mount
useEffect(() => {
connectionModeService.getCurrentMode().then(mode => setConnectionMode(mode));
connectionModeService.getCurrentMode().then((mode) => setConnectionMode(mode));
}, []);
// Accept invitation handler
@@ -30,18 +30,18 @@ export function TeamInvitationBanner() {
try {
await acceptInvitation(invitation.invitationToken);
console.log('[TeamInvitationBanner] Invitation accepted successfully:', invitation.teamName);
console.log("[TeamInvitationBanner] Invitation accepted successfully:", invitation.teamName);
// Wait briefly for backend to process team membership update
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000));
// Refresh billing after joining team (tier may have changed)
console.log('[TeamInvitationBanner] Refreshing billing after team join...');
console.log("[TeamInvitationBanner] Refreshing billing after team join...");
await refreshBilling();
setDismissed(true);
} catch (error) {
console.error('[TeamInvitationBanner] Failed to accept invitation:', error);
console.error("[TeamInvitationBanner] Failed to accept invitation:", error);
} finally {
setProcessing(false);
}
@@ -56,28 +56,25 @@ export function TeamInvitationBanner() {
try {
await rejectInvitation(invitation.invitationToken);
console.log('[TeamInvitationBanner] Invitation rejected');
console.log("[TeamInvitationBanner] Invitation rejected");
setDismissed(true);
} catch (error) {
console.error('[TeamInvitationBanner] Failed to reject invitation:', error);
console.error("[TeamInvitationBanner] Failed to reject invitation:", error);
} finally {
setProcessing(false);
}
};
// Visibility logic
const shouldShow =
connectionMode === 'saas' &&
!dismissed &&
receivedInvitations.length > 0;
const shouldShow = connectionMode === "saas" && !dismissed && receivedInvitations.length > 0;
if (!shouldShow) return null;
const invitation = receivedInvitations[0]; // Show first invitation
const message = (
<Text component="span" size="sm" fw={500} style={{ color: 'rgba(255, 255, 255, 0.95)' }}>
<strong>{invitation.inviterEmail}</strong> {t('team.invitationBanner.message', 'has invited you to join')}{' '}
<Text component="span" size="sm" fw={500} style={{ color: "rgba(255, 255, 255, 0.95)" }}>
<strong>{invitation.inviterEmail}</strong> {t("team.invitationBanner.message", "has invited you to join")}{" "}
<strong>{invitation.teamName}</strong>
</Text>
);
@@ -90,23 +87,25 @@ export function TeamInvitationBanner() {
size="xs"
onClick={handleAccept}
loading={processing}
leftSection={<LocalIcon icon="check" width="0.9rem" height="0.9rem" style={{ color: 'var(--mantine-color-dark-9)' }} />}
leftSection={
<LocalIcon icon="check" width="0.9rem" height="0.9rem" style={{ color: "var(--mantine-color-dark-9)" }} />
}
styles={{
label: {
color: 'var(--mantine-color-dark-9)',
color: "var(--mantine-color-dark-9)",
},
}}
>
{t('team.invitationBanner.acceptButton', 'Accept')}
{t("team.invitationBanner.acceptButton", "Accept")}
</Button>
<Button
variant="subtle"
size="xs"
onClick={handleReject}
loading={processing}
style={{ color: 'rgba(255, 255, 255, 0.7)' }}
style={{ color: "rgba(255, 255, 255, 0.7)" }}
>
{t('team.invitationBanner.rejectButton', 'Decline')}
{t("team.invitationBanner.rejectButton", "Decline")}
</Button>
</Group>
);
@@ -115,7 +114,7 @@ export function TeamInvitationBanner() {
<InfoBanner
icon="mail"
message={
<Group justify="space-between" align="center" wrap="nowrap" style={{ width: '100%' }}>
<Group justify="space-between" align="center" wrap="nowrap" style={{ width: "100%" }}>
{message}
{actionButtons}
</Group>
@@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { saasBillingService } from '@app/services/saasBillingService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser';
import React, { useState, useEffect } from "react";
import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { saasBillingService } from "@app/services/saasBillingService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import OpenInBrowserIcon from "@mui/icons-material/OpenInBrowser";
type CheckoutState = {
status: 'idle' | 'loading' | 'opened' | 'refreshing' | 'error';
status: "idle" | "loading" | "opened" | "refreshing" | "error";
error?: string;
sessionPlanId?: string;
};
@@ -18,53 +18,45 @@ interface SaaSStripeCheckoutProps {
onSuccess?: () => void;
}
export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
opened,
onClose,
planId,
onSuccess
}) => {
export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({ opened, onClose, planId, onSuccess }) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
const [state, setState] = useState<CheckoutState>({ status: "idle" });
const createCheckoutSession = async () => {
if (!planId) {
setState({ status: 'error', error: 'No plan selected' });
setState({ status: "error", error: "No plan selected" });
return;
}
try {
setState({ status: 'loading' });
setState({ status: "loading" });
// Map UI plan IDs to Stripe plan IDs
const stripePlanId = planId === 'team' ? 'pro' : planId;
const stripePlanId = planId === "team" ? "pro" : planId;
// Open checkout in browser (returns void, opens browser window)
await saasBillingService.openCheckout(
stripePlanId as 'pro',
window.location.origin
);
await saasBillingService.openCheckout(stripePlanId as "pro", window.location.origin);
setState({
status: 'opened',
sessionPlanId: planId
status: "opened",
sessionPlanId: planId,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
console.error('[SaaSStripeCheckout] Error creating checkout:', err);
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
console.error("[SaaSStripeCheckout] Error creating checkout:", err);
setState({
status: 'error',
error: errorMessage
status: "error",
error: errorMessage,
});
}
};
const handleRefreshClick = async () => {
console.log('[SaaSStripeCheckout] User requested refresh after checkout');
setState({ ...state, status: 'refreshing' });
console.log("[SaaSStripeCheckout] User requested refresh after checkout");
setState({ ...state, status: "refreshing" });
// Give Stripe webhooks a moment to process (2-3 seconds)
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
// Trigger the refresh
if (onSuccess) {
@@ -77,7 +69,7 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
const handleClose = () => {
// Reset state to idle to clean up the session
setState({ status: 'idle', error: undefined, sessionPlanId: undefined });
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
onClose();
};
@@ -85,66 +77,57 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
useEffect(() => {
if (opened) {
// Check if we need a new session (first time or plan changed)
const needsNewSession =
state.status === 'idle' ||
!state.sessionPlanId ||
state.sessionPlanId !== planId;
const needsNewSession = state.status === "idle" || !state.sessionPlanId || state.sessionPlanId !== planId;
if (needsNewSession) {
console.log('[SaaSStripeCheckout] Opening checkout in browser for plan:', planId);
console.log("[SaaSStripeCheckout] Opening checkout in browser for plan:", planId);
createCheckoutSession();
}
} else if (!opened) {
// Clean up state when modal closes
setState({ status: 'idle', error: undefined, sessionPlanId: undefined });
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
}
}, [opened, planId]);
const renderContent = () => {
switch (state.status) {
case 'loading':
case "loading":
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</div>
);
case 'opened':
case "opened":
return (
<Alert color="blue" title={t('payment.checkoutOpened', 'Checkout Opened in Browser')} icon={<OpenInBrowserIcon />}>
<Alert color="blue" title={t("payment.checkoutOpened", "Checkout Opened in Browser")} icon={<OpenInBrowserIcon />}>
<Stack gap="md">
<Text size="sm">
{t('payment.checkoutInstructions', 'Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information.')}
{t(
"payment.checkoutInstructions",
"Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information.",
)}
</Text>
<Button
variant="filled"
color="blue"
onClick={handleRefreshClick}
fullWidth
>
{t('payment.refreshBilling', 'I\'ve Completed Payment - Refresh Billing')}
<Button variant="filled" color="blue" onClick={handleRefreshClick} fullWidth>
{t("payment.refreshBilling", "I've Completed Payment - Refresh Billing")}
</Button>
<Button
variant="subtle"
onClick={handleClose}
fullWidth
>
{t('payment.closeLater', 'I\'ll Do This Later')}
<Button variant="subtle" onClick={handleClose} fullWidth>
{t("payment.closeLater", "I'll Do This Later")}
</Button>
</Stack>
</Alert>
);
case 'error':
case "error":
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
@@ -156,9 +139,9 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
};
const getPlanName = () => {
if (planId === 'team') return t('plan.team.name', 'Team');
if (planId === 'enterprise') return t('plan.enterprise.name', 'Enterprise');
return t('plan.free.name', 'Free');
if (planId === "team") return t("plan.team.name", "Team");
if (planId === "enterprise") return t("plan.enterprise.name", "Enterprise");
return t("plan.free.name", "Free");
};
return (
@@ -168,7 +151,7 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
title={
<div>
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: getPlanName() })}
{t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName: getPlanName() })}
</Text>
</div>
}
@@ -1,14 +1,17 @@
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react';
import { useConfigNavSections as useProprietaryConfigNavSections, createConfigNavSections as createProprietaryConfigNavSections } from '@proprietary/components/shared/config/configNavSections';
import { ConfigNavSection } from '@core/components/shared/config/configNavSections';
import { ConnectionSettings } from '@app/components/ConnectionSettings';
import { SaasPlanSection } from '@app/components/shared/config/configSections/SaasPlanSection';
import { SaaSTeamsSection } from '@app/components/shared/config/configSections/SaaSTeamsSection';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import {
useConfigNavSections as useProprietaryConfigNavSections,
createConfigNavSections as createProprietaryConfigNavSections,
} from "@proprietary/components/shared/config/configNavSections";
import { ConfigNavSection } from "@core/components/shared/config/configNavSections";
import { ConnectionSettings } from "@app/components/ConnectionSettings";
import { SaasPlanSection } from "@app/components/shared/config/configSections/SaasPlanSection";
import { SaaSTeamsSection } from "@app/components/shared/config/configSections/SaaSTeamsSection";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
export type { ConfigNavSection, ConfigNavItem } from "@core/components/shared/config/configNavSections";
/**
* Hook version of desktop config nav sections with proper i18n support
@@ -16,7 +19,7 @@ export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/co
export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
loginEnabled: boolean = false,
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -25,30 +28,30 @@ export const useConfigNavSections = (
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
return connectionModeService.subscribeToModeChanges((config) => setConnectionMode(config.mode));
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
const isSaasMode = connectionMode === 'saas';
const isLocalMode = connectionMode === 'local';
const isSaasMode = connectionMode === "saas";
const isLocalMode = connectionMode === "local";
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
const connectionModeSection: ConfigNavSection = {
title: t('settings.connection.title', 'Connection Mode'),
title: t("settings.connection.title", "Connection Mode"),
items: [
{
key: 'connectionMode',
label: t('settings.connection.title', 'Connection Mode'),
icon: 'desktop-cloud-rounded',
key: "connectionMode",
label: t("settings.connection.title", "Connection Mode"),
icon: "desktop-cloud-rounded",
component: <ConnectionSettings />,
},
],
@@ -66,11 +69,11 @@ export const useConfigNavSections = (
// Identifies self-hosted admin sections by their first item's stable key.
// Using item keys avoids dependency on translated section titles (#17).
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
'people', // Workspace section
'adminGeneral', // Configuration section
'adminSecurity', // Security & Authentication section
'adminPlan', // Licensing & Analytics section
'adminLegal', // Policies & Privacy section
"people", // Workspace section
"adminGeneral", // Configuration section
"adminSecurity", // Security & Authentication section
"adminPlan", // Licensing & Analytics section
"adminLegal", // Policies & Privacy section
]);
// Build the result array explicitly instead of splice with hardcoded indices (#18).
@@ -85,23 +88,23 @@ export const useConfigNavSections = (
// Plan & Billing and Team sections only when authenticated in SaaS mode
if (isSaasMode && isAuthenticated) {
result.push({
title: t('settings.planBilling.title', 'Plan & Billing'),
title: t("settings.planBilling.title", "Plan & Billing"),
items: [
{
key: 'planBilling',
label: t('settings.planBilling.title', 'Plan & Billing'),
icon: 'credit-card',
key: "planBilling",
label: t("settings.planBilling.title", "Plan & Billing"),
icon: "credit-card",
component: <SaasPlanSection />,
},
],
});
result.push({
title: t('settings.team.title', 'Team'),
title: t("settings.team.title", "Team"),
items: [
{
key: 'teams',
label: t('settings.team.title', 'Team'),
icon: 'groups-rounded',
key: "teams",
label: t("settings.team.title", "Team"),
icon: "groups-rounded",
component: <SaaSTeamsSection />,
},
],
@@ -115,9 +118,7 @@ export const useConfigNavSections = (
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
continue;
}
const filteredItems = isAuthenticated
? section.items
: section.items.filter(item => item.key !== 'account');
const filteredItems = isAuthenticated ? section.items : section.items.filter((item) => item.key !== "account");
if (filteredItems.length === 0) continue;
result.push({ ...section, items: filteredItems });
}
@@ -132,21 +133,21 @@ export const useConfigNavSections = (
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn('createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.');
console.warn("createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.");
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = createProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Connection section at the beginning (after Preferences)
sections.splice(1, 0, {
title: 'Connection',
title: "Connection",
items: [
{
key: 'connectionMode',
label: 'Connection Mode',
icon: 'desktop-cloud-rounded',
key: "connectionMode",
label: "Connection Mode",
icon: "desktop-cloud-rounded",
component: <ConnectionSettings />,
},
],
@@ -154,12 +155,12 @@ export const createConfigNavSections = (
// Add Plan & Billing section (after Connection Mode)
sections.splice(2, 0, {
title: 'Plan & Billing',
title: "Plan & Billing",
items: [
{
key: 'planBilling',
label: 'Plan & Billing',
icon: 'credit-card',
key: "planBilling",
label: "Plan & Billing",
icon: "credit-card",
component: <SaasPlanSection />,
},
],
@@ -1,7 +1,7 @@
import React from 'react';
import { Paper, Text, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
import React from "react";
import { Paper, Text, Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppSettings: React.FC = () => {
const { t } = useTranslation();
@@ -12,18 +12,18 @@ export const DefaultAppSettings: React.FC = () => {
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
{t("settings.general.defaultPdfEditor", "Default PDF editor")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{isDefault === true
? t('settings.general.defaultPdfEditorActive', 'Stirling PDF is your default PDF editor')
? t("settings.general.defaultPdfEditorActive", "Stirling PDF is your default PDF editor")
: isDefault === false
? t('settings.general.defaultPdfEditorInactive', 'Another application is set as default')
: t('settings.general.defaultPdfEditorChecking', 'Checking...')}
? t("settings.general.defaultPdfEditorInactive", "Another application is set as default")
: t("settings.general.defaultPdfEditorChecking", "Checking...")}
</Text>
</div>
<Button
variant={isDefault ? 'light' : 'filled'}
variant={isDefault ? "light" : "filled"}
color="blue"
size="sm"
onClick={handleSetDefault}
@@ -31,8 +31,8 @@ export const DefaultAppSettings: React.FC = () => {
disabled={isDefault === true}
>
{isDefault
? t('settings.general.defaultPdfEditorSet', 'Already Default')
: t('settings.general.setAsDefault', 'Set as Default')}
? t("settings.general.defaultPdfEditorSet", "Already Default")
: t("settings.general.setAsDefault", "Set as Default")}
</Button>
</Group>
</Paper>
@@ -1,7 +1,7 @@
import React from 'react';
import { Stack } from '@mantine/core';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
import { DefaultAppSettings } from '@app/components/shared/config/configSections/DefaultAppSettings';
import React from "react";
import { Stack } from "@mantine/core";
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
@@ -1,11 +1,27 @@
import React, { useState, useEffect } from 'react';
import { Button, TextInput, Group, Text, Stack, Alert, Table, Badge, ActionIcon, Menu, List, ThemeIcon, Modal, CloseButton, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import apiClient from '@app/services/apiClient';
import React, { useState, useEffect } from "react";
import {
Button,
TextInput,
Group,
Text,
Stack,
Alert,
Table,
Badge,
ActionIcon,
Menu,
List,
ThemeIcon,
Modal,
CloseButton,
Anchor,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import apiClient from "@app/services/apiClient";
/**
* Desktop SaaS Teams Section
@@ -29,9 +45,9 @@ export function SaaSTeamsSection() {
// Check Pro status via billing context
const { tier } = useSaaSBilling();
const isPro = tier !== 'free';
const isPro = tier !== "free";
const [inviteEmail, setInviteEmail] = useState('');
const [inviteEmail, setInviteEmail] = useState("");
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@@ -39,7 +55,7 @@ export function SaaSTeamsSection() {
// Team rename state
const [isEditingName, setIsEditingName] = useState(false);
const [newTeamName, setNewTeamName] = useState('');
const [newTeamName, setNewTeamName] = useState("");
const [renamingTeam, setRenamingTeam] = useState(false);
// Refresh team data on mount and every 10 seconds
@@ -56,7 +72,7 @@ export function SaaSTeamsSection() {
}, []); // Only run on mount/unmount
const navigateToPlan = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'planBilling' } }));
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }));
};
const handleInvite = async (e: React.FormEvent) => {
@@ -69,37 +85,37 @@ export function SaaSTeamsSection() {
try {
await inviteUser(inviteEmail);
setSuccess(t('team.inviteSent', 'Invitation sent to {{email}}', { email: inviteEmail }));
setInviteEmail('');
setSuccess(t("team.inviteSent", "Invitation sent to {{email}}", { email: inviteEmail }));
setInviteEmail("");
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.inviteError', 'Failed to send invitation'));
setError(error.response?.data?.error || t("team.inviteError", "Failed to send invitation"));
} finally {
setInviting(false);
}
};
const handleRemove = async (memberId: number, memberEmail: string) => {
if (!window.confirm(t('team.confirmRemove', 'Remove {{email}} from the team?', { email: memberEmail }))) return;
if (!window.confirm(t("team.confirmRemove", "Remove {{email}} from the team?", { email: memberEmail }))) return;
try {
await removeMember(memberId);
setSuccess(t('team.memberRemoved', 'Member removed successfully'));
setSuccess(t("team.memberRemoved", "Member removed successfully"));
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.removeError', 'Failed to remove member'));
setError(error.response?.data?.error || t("team.removeError", "Failed to remove member"));
}
};
const handleCancelInvitation = async (invitationId: number, email: string) => {
if (!window.confirm(t('team.confirmCancelInvite', 'Cancel invitation for {{email}}?', { email }))) return;
if (!window.confirm(t("team.confirmCancelInvite", "Cancel invitation for {{email}}?", { email }))) return;
try {
await cancelInvitation(invitationId);
setSuccess(t('team.inviteCancelled', 'Invitation for {{email}} cancelled', { email }));
setSuccess(t("team.inviteCancelled", "Invitation for {{email}} cancelled", { email }));
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.cancelInviteError', 'Failed to cancel invitation'));
setError(error.response?.data?.error || t("team.cancelInviteError", "Failed to cancel invitation"));
}
};
@@ -112,7 +128,7 @@ export function SaaSTeamsSection() {
const handleCancelRename = () => {
setIsEditingName(false);
setNewTeamName('');
setNewTeamName("");
};
const handleRenameSubmit = async () => {
@@ -126,12 +142,12 @@ export function SaaSTeamsSection() {
newName: newTeamName.trim(),
});
setSuccess(t('team.renameSuccess', 'Team renamed successfully'));
setSuccess(t("team.renameSuccess", "Team renamed successfully"));
setIsEditingName(false);
await refreshTeams();
} catch (err) {
const error = err as { response?: { data?: { error?: string } }; message?: string };
setError(error.response?.data?.error || error.message || t('team.renameError', 'Failed to rename team'));
setError(error.response?.data?.error || error.message || t("team.renameError", "Failed to rename team"));
} finally {
setRenamingTeam(false);
}
@@ -141,24 +157,28 @@ export function SaaSTeamsSection() {
if (!currentTeam || isPersonalTeam) return;
const confirmMessage = isTeamLeader
? t('team.confirmLeaveLeader', 'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.', { name: currentTeam.name })
: t('team.confirmLeave', 'Are you sure you want to leave "{{name}}"?', { name: currentTeam.name });
? t(
"team.confirmLeaveLeader",
'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.',
{ name: currentTeam.name },
)
: t("team.confirmLeave", 'Are you sure you want to leave "{{name}}"?', { name: currentTeam.name });
if (!window.confirm(confirmMessage)) return;
try {
await leaveTeam();
setSuccess(t('team.leaveSuccess', 'Successfully left team'));
setSuccess(t("team.leaveSuccess", "Successfully left team"));
} catch (err) {
const error = err as { response?: { data?: { error?: string } }; message?: string };
setError(error.response?.data?.error || error.message || t('team.leaveError', 'Failed to leave team'));
setError(error.response?.data?.error || error.message || t("team.leaveError", "Failed to leave team"));
}
};
if (!currentTeam) {
return (
<Alert color="gray">
<Text>{t('team.loading', 'Loading team information...')}</Text>
<Text>{t("team.loading", "Loading team information...")}</Text>
</Alert>
);
}
@@ -174,12 +194,12 @@ export function SaaSTeamsSection() {
<TextInput
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder={t('team.namePlaceholder', 'Team name')}
placeholder={t("team.namePlaceholder", "Team name")}
style={{ flex: 1, maxWidth: 300 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameSubmit();
if (e.key === 'Escape') handleCancelRename();
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") handleCancelRename();
}}
/>
<ActionIcon
@@ -191,37 +211,36 @@ export function SaaSTeamsSection() {
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleCancelRename}
disabled={renamingTeam}
>
<ActionIcon variant="subtle" color="gray" onClick={handleCancelRename} disabled={renamingTeam}>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
) : (
<Group gap="xs" align="center">
<Text fw={600} size="lg">{currentTeam.name}</Text>
<Text fw={600} size="lg">
{currentTeam.name}
</Text>
{isTeamLeader && !isPersonalTeam && (
<ActionIcon
variant="subtle"
size="sm"
onClick={handleStartRename}
aria-label={t('team.editName', 'Edit team name')}
aria-label={t("team.editName", "Edit team name")}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
)}
{isTeamLeader && <Badge color='blue'>{t('team.leader', 'LEADER')}</Badge>}
{isTeamLeader && <Badge color="blue">{t("team.leader", "LEADER")}</Badge>}
{isPersonalTeam && (
<Badge color="gray" variant="light" size="xs">{t('team.personal', 'Personal')}</Badge>
<Badge color="gray" variant="light" size="xs">
{t("team.personal", "Personal")}
</Badge>
)}
</Group>
)}
{!isEditingName && !isPersonalTeam && (
<Text size="sm" c="dimmed" mt={4}>
{t('team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })}
{t("team.memberCount", "{{count}} team members", { count: currentTeam.seatsUsed })}
</Text>
)}
</div>
@@ -233,7 +252,7 @@ export function SaaSTeamsSection() {
onClick={handleLeaveTeam}
leftSection={<LocalIcon icon="logout" width="1rem" height="1rem" />}
>
{t('team.leaveButton', 'Leave Team')}
{t("team.leaveButton", "Leave Team")}
</Button>
)}
</Group>
@@ -244,20 +263,18 @@ export function SaaSTeamsSection() {
<Alert color="blue" icon={<LocalIcon icon="info" width={16} height={16} />}>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">{t('team.upgrade.title', 'Upgrade to Pro to unlock team features')}</Text>
<Text fw={500} size="sm">
{t("team.upgrade.title", "Upgrade to Pro to unlock team features")}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{t('team.upgrade.description', 'Invite members, share credits, and more.')}{' '}
<Anchor size="xs" onClick={() => setFeaturesModalOpened(true)} style={{ cursor: 'pointer' }}>
{t('common.learnMore', 'Learn more')}
{t("team.upgrade.description", "Invite members, share credits, and more.")}{" "}
<Anchor size="xs" onClick={() => setFeaturesModalOpened(true)} style={{ cursor: "pointer" }}>
{t("common.learnMore", "Learn more")}
</Anchor>
</Text>
</div>
<Button
size="sm"
variant="light"
onClick={navigateToPlan}
>
{t('team.upgrade.button', 'Upgrade to Pro')}
<Button size="sm" variant="light" onClick={navigateToPlan}>
{t("team.upgrade.button", "Upgrade to Pro")}
</Button>
</Group>
</Alert>
@@ -273,26 +290,28 @@ export function SaaSTeamsSection() {
withCloseButton={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setFeaturesModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header */}
<Stack gap="md" align="center">
<Badge size="lg" color="violet" variant="filled">{t('team.features.badge', 'PRO FEATURE')}</Badge>
<Badge size="lg" color="violet" variant="filled">
{t("team.features.badge", "PRO FEATURE")}
</Badge>
<Text size="xl" fw={700} ta="center">
{t('team.features.title', 'Team Collaboration')}
{t("team.features.title", "Team Collaboration")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('team.features.subtitle', 'Upgrade to Pro and unlock powerful team features')}
{t("team.features.subtitle", "Upgrade to Pro and unlock powerful team features")}
</Text>
</Stack>
@@ -307,20 +326,28 @@ export function SaaSTeamsSection() {
}
>
<List.Item>
<Text fw={500}>{t('team.features.invite.title', 'Invite team members')}</Text>
<Text size="xs" c="dimmed">{t('team.features.invite.description', 'Add unlimited users with additional seat purchases')}</Text>
<Text fw={500}>{t("team.features.invite.title", "Invite team members")}</Text>
<Text size="xs" c="dimmed">
{t("team.features.invite.description", "Add unlimited users with additional seat purchases")}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.credits.title', 'Share credits across your team')}</Text>
<Text size="xs" c="dimmed">{t('team.features.credits.description', 'Pool resources for collaborative work')}</Text>
<Text fw={500}>{t("team.features.credits.title", "Share credits across your team")}</Text>
<Text size="xs" c="dimmed">
{t("team.features.credits.description", "Pool resources for collaborative work")}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.dashboard.title', 'Team management dashboard')}</Text>
<Text size="xs" c="dimmed">{t('team.features.dashboard.description', 'Control permissions, monitor usage, and manage members')}</Text>
<Text fw={500}>{t("team.features.dashboard.title", "Team management dashboard")}</Text>
<Text size="xs" c="dimmed">
{t("team.features.dashboard.description", "Control permissions, monitor usage, and manage members")}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.billing.title', 'Centralized billing')}</Text>
<Text size="xs" c="dimmed">{t('team.features.billing.description', 'One invoice for all team seats and usage')}</Text>
<Text fw={500}>{t("team.features.billing.title", "Centralized billing")}</Text>
<Text size="xs" c="dimmed">
{t("team.features.billing.description", "One invoice for all team seats and usage")}
</Text>
</List.Item>
</List>
@@ -333,7 +360,7 @@ export function SaaSTeamsSection() {
navigateToPlan();
}}
>
{t('team.features.viewPlans', 'View Pro Plans')}
{t("team.features.viewPlans", "View Pro Plans")}
</Button>
</Stack>
</div>
@@ -355,24 +382,30 @@ export function SaaSTeamsSection() {
{/* Invite Members (Pro Users) */}
{isTeamLeader && isPro && (
<div>
<Text fw={600} size="md" mb="sm">{t('team.invite.title', 'Invite Team Member')}</Text>
<Text fw={600} size="md" mb="sm">
{t("team.invite.title", "Invite Team Member")}
</Text>
<form onSubmit={handleInvite}>
<Group>
<TextInput
type="email"
placeholder={t('team.invite.placeholder', '[email protected]')}
placeholder={t("team.invite.placeholder", "[email protected]")}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
style={{ flex: 1 }}
required
error={inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) ? t('team.invite.invalidEmail', 'Invalid email format') : undefined}
error={
inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
? t("team.invite.invalidEmail", "Invalid email format")
: undefined
}
/>
<Button
type="submit"
loading={inviting}
disabled={!inviteEmail.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)}
>
{t('team.invite.sendButton', 'Send Invite')}
{t("team.invite.sendButton", "Send Invite")}
</Button>
</Group>
</form>
@@ -381,30 +414,32 @@ export function SaaSTeamsSection() {
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">{t('team.members.title', 'Team Members')}</Text>
<Text fw={600} size="md" mb="sm">
{t("team.members.title", "Team Members")}
</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.nameColumn', 'Name')}
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--mantine-color-gray-7)" }}>
{t("team.members.nameColumn", "Name")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.emailColumn', 'Email')}
<Table.Th style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--mantine-color-gray-7)" }}>
{t("team.members.emailColumn", "Email")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.roleColumn', 'Role')}
<Table.Th style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--mantine-color-gray-7)" }}>
{t("team.members.roleColumn", "Role")}
</Table.Th>
{isTeamLeader && !isPersonalTeam && (
<Table.Th style={{ width: 50 }}></Table.Th>
)}
{isTeamLeader && !isPersonalTeam && <Table.Th style={{ width: 50 }}></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -412,7 +447,7 @@ export function SaaSTeamsSection() {
<Table.Tr>
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
<Text ta="center" c="dimmed" py="xl">
{t('team.members.empty', 'No team members yet.')}
{t("team.members.empty", "No team members yet.")}
</Text>
</Table.Td>
</Table.Tr>
@@ -434,18 +469,22 @@ export function SaaSTeamsSection() {
<Table.Td>
<Badge
size="sm"
color={member.role === 'LEADER' ? 'blue' : undefined}
style={member.role !== 'LEADER' ? {
backgroundColor: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
} : undefined}
color={member.role === "LEADER" ? "blue" : undefined}
style={
member.role !== "LEADER"
? {
backgroundColor: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
}
: undefined
}
>
{member.role}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
{member.role !== 'LEADER' && (
{member.role !== "LEADER" && (
<Menu position="bottom-end" withinPortal zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Menu.Target>
<ActionIcon variant="subtle">
@@ -458,7 +497,7 @@ export function SaaSTeamsSection() {
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
onClick={() => handleRemove(member.id, member.email)}
>
{t('team.members.remove', 'Remove from Team')}
{t("team.members.remove", "Remove from Team")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -470,12 +509,12 @@ export function SaaSTeamsSection() {
{/* Pending Invitations */}
{teamInvitations
.filter(inv => inv.status === 'PENDING')
.filter((inv) => inv.status === "PENDING")
.map((invitation) => (
<Table.Tr key={`invitation-${invitation.invitationId}`}>
<Table.Td>
<Text size="sm" fw={500} c="dimmed" fs="italic">
{invitation.inviteeEmail.split('@')[0]}
{invitation.inviteeEmail.split("@")[0]}
</Text>
</Table.Td>
<Table.Td>
@@ -485,7 +524,7 @@ export function SaaSTeamsSection() {
</Table.Td>
<Table.Td>
<Badge size="sm" color="yellow" variant="light">
{t('team.members.pending', 'PENDING')}
{t("team.members.pending", "PENDING")}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
@@ -494,7 +533,7 @@ export function SaaSTeamsSection() {
variant="subtle"
color="red"
onClick={() => handleCancelInvitation(invitation.invitationId, invitation.inviteeEmail)}
aria-label={t('team.invite.cancelLabel', 'Cancel invitation')}
aria-label={t("team.invite.cancelLabel", "Cancel invitation")}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
@@ -507,7 +546,6 @@ export function SaaSTeamsSection() {
</Table.Tbody>
</Table>
</div>
</Stack>
);
}
@@ -1,16 +1,16 @@
import { useEffect, useState } from 'react';
import { Stack, Loader, Alert, Button, Center, Text, Flex } from '@mantine/core';
import RefreshIcon from '@mui/icons-material/Refresh';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSPlans } from '@app/hooks/useSaaSPlans';
import { connectionModeService } from '@app/services/connectionModeService';
import { SaaSCheckoutProvider } from '@app/contexts/SaaSCheckoutContext';
import { ActiveSubscriptionCard } from '@app/components/shared/config/configSections/plan/ActiveSubscriptionCard';
import { SaaSAvailablePlansSection } from '@app/components/shared/config/configSections/plan/SaaSAvailablePlansSection';
import { useEffect, useState } from "react";
import { Stack, Loader, Alert, Button, Center, Text, Flex } from "@mantine/core";
import RefreshIcon from "@mui/icons-material/Refresh";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import { useTranslation } from "react-i18next";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSPlans } from "@app/hooks/useSaaSPlans";
import { connectionModeService } from "@app/services/connectionModeService";
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
import { ActiveSubscriptionCard } from "@app/components/shared/config/configSections/plan/ActiveSubscriptionCard";
import { SaaSAvailablePlansSection } from "@app/components/shared/config/configSections/plan/SaaSAvailablePlansSection";
/**
* SaaS Plan & Billing section
@@ -48,14 +48,14 @@ export function SaasPlanSection() {
useEffect(() => {
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
setIsSaasMode(mode === 'saas');
setIsSaasMode(mode === "saas");
};
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(async (config) => {
setIsSaasMode(config.mode === 'saas');
setIsSaasMode(config.mode === "saas");
});
return unsubscribe;
@@ -69,7 +69,7 @@ export function SaasPlanSection() {
// Context handles opening portal and auto-refresh
await openBillingPortal();
} catch (error) {
console.error('[SaasPlanSection] Failed to open billing portal:', error);
console.error("[SaasPlanSection] Failed to open billing portal:", error);
} finally {
setIsOpeningPortal(false);
}
@@ -78,9 +78,9 @@ export function SaasPlanSection() {
// Format date for trial end
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
year: "numeric",
month: "long",
day: "numeric",
});
};
@@ -91,8 +91,8 @@ export function SaasPlanSection() {
<Alert color="blue" variant="light" icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}>
<Text size="sm">
{t(
'settings.planBilling.notAvailable',
'Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).'
"settings.planBilling.notAvailable",
"Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).",
)}
</Text>
</Alert>
@@ -117,7 +117,7 @@ export function SaasPlanSection() {
<Stack align="center" gap="md">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('settings.planBilling.loading', 'Loading billing information...')}
{t("settings.planBilling.loading", "Loading billing information...")}
</Text>
</Stack>
</Center>
@@ -132,17 +132,12 @@ export function SaasPlanSection() {
color="red"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
title={t('settings.planBilling.errors.fetchFailed', 'Unable to fetch billing data')}
title={t("settings.planBilling.errors.fetchFailed", "Unable to fetch billing data")}
>
<Stack gap="sm">
<Text size="sm">{error}</Text>
<Button
variant="light"
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
onClick={refreshBilling}
size="xs"
>
{t('settings.planBilling.errors.retry', 'Retry')}
<Button variant="light" leftSection={<RefreshIcon sx={{ fontSize: 16 }} />} onClick={refreshBilling} size="xs">
{t("settings.planBilling.errors.retry", "Retry")}
</Button>
</Stack>
</Alert>
@@ -156,10 +151,10 @@ export function SaasPlanSection() {
<div>
{/* Header with title and Manage Billing button */}
<Flex justify="space-between" align="center" mb="md">
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('settings.planBilling.currentPlan', 'Active Plan')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("settings.planBilling.currentPlan", "Active Plan")}
</h3>
{tier !== 'free' && !isManagedTeamMember && (
{tier !== "free" && !isManagedTeamMember && (
<Button
variant="light"
size="sm"
@@ -167,7 +162,7 @@ export function SaasPlanSection() {
loading={isOpeningPortal}
disabled={isOpeningPortal}
>
{t('settings.planBilling.billing.manageBilling', 'Manage Billing')}
{t("settings.planBilling.billing.manageBilling", "Manage Billing")}
</Button>
)}
</Flex>
@@ -179,16 +174,16 @@ export function SaasPlanSection() {
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('settings.planBilling.trial.title', 'Free Trial Active')}
title={t("settings.planBilling.trial.title", "Free Trial Active")}
>
<Text size="sm">
{t('settings.planBilling.trial.daysRemainingFull', 'Your trial ends in {{days}} days', {
{t("settings.planBilling.trial.daysRemainingFull", "Your trial ends in {{days}} days", {
days: trialDaysRemaining,
defaultValue: `Your trial ends in ${trialDaysRemaining} days`,
})}
</Text>
<Text size="xs" c="dimmed">
{t('settings.planBilling.trial.endDate', 'Expires: {{date}}', {
{t("settings.planBilling.trial.endDate", "Expires: {{date}}", {
date: formatDate(subscription.currentPeriodEnd),
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
})}
@@ -212,12 +207,7 @@ export function SaasPlanSection() {
/>
{/* Available plans grid */}
<SaaSAvailablePlansSection
plans={plans}
currentTier={tier}
loading={plansLoading}
error={plansError}
/>
<SaaSAvailablePlansSection plans={plans} currentTier={tier} loading={plansLoading} error={plansError} />
</Stack>
</div>
</SaaSCheckoutProvider>
@@ -1,10 +1,10 @@
import { Card, Text, Group, Badge, Stack, Tooltip, ActionIcon } from '@mantine/core';
import GroupIcon from '@mui/icons-material/Group';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { useTranslation } from 'react-i18next';
import type { BillingStatus } from '@app/services/saasBillingService';
import { BILLING_CONFIG, getFormattedOveragePrice } from '@app/config/billing';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { Card, Text, Group, Badge, Stack, Tooltip, ActionIcon } from "@mantine/core";
import GroupIcon from "@mui/icons-material/Group";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { useTranslation } from "react-i18next";
import type { BillingStatus } from "@app/services/saasBillingService";
import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface TeamData {
teamId: number;
@@ -15,9 +15,9 @@ interface TeamData {
}
interface ActiveSubscriptionCardProps {
tier: BillingStatus['tier'];
subscription: BillingStatus['subscription'];
usage: BillingStatus['meterUsage'];
tier: BillingStatus["tier"];
subscription: BillingStatus["subscription"];
usage: BillingStatus["meterUsage"];
isTrialing: boolean;
price?: number;
currency?: string;
@@ -42,21 +42,21 @@ export function ActiveSubscriptionCard({
// Format timestamp to readable date
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
year: "numeric",
month: "long",
day: "numeric",
});
};
// Get tier display name
const getTierName = (): string => {
switch (tier) {
case 'free':
return t('settings.planBilling.tier.free', 'Free Plan');
case 'team':
return t('settings.planBilling.tier.team', 'Team Plan');
case 'enterprise':
return t('settings.planBilling.tier.enterprise', 'Enterprise Plan');
case "free":
return t("settings.planBilling.tier.free", "Free Plan");
case "team":
return t("settings.planBilling.tier.team", "Team Plan");
case "enterprise":
return t("settings.planBilling.tier.enterprise", "Enterprise Plan");
default:
return tier;
}
@@ -64,31 +64,31 @@ export function ActiveSubscriptionCard({
// Get price display
const getPriceDisplay = (): string => {
if (tier === 'free') {
return '$0/month';
if (tier === "free") {
return "$0/month";
}
// Use actual price from Stripe if available
if (price !== undefined && currency) {
return `${currency}${price}/month`;
}
// Fallback to default pricing
return '$10/month';
return "$10/month";
};
// Get description
const getDescription = (): string => {
if (tier === 'free') {
return t('settings.planBilling.tier.freeDescription', '50 credits per month');
if (tier === "free") {
return t("settings.planBilling.tier.freeDescription", "50 credits per month");
}
return t(
'settings.planBilling.tier.teamDescription',
'500 credits/month included, automatic overage billing for uninterrupted service'
"settings.planBilling.tier.teamDescription",
"500 credits/month included, automatic overage billing for uninterrupted service",
);
};
// Format overage cost
const formatOverageCost = (cents: number, credits: number): string => {
return t('settings.planBilling.billing.overageCost', {
return t("settings.planBilling.billing.overageCost", {
amount: `$${(cents / 100).toFixed(2)}`,
credits,
defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`,
@@ -96,7 +96,7 @@ export function ActiveSubscriptionCard({
};
// Pro/Team card
if (tier === 'team' || tier === 'enterprise') {
if (tier === "team" || tier === "enterprise") {
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="sm">
@@ -105,30 +105,33 @@ export function ActiveSubscriptionCard({
<div style={{ flex: 1 }}>
<Group gap="xs" mb="xs">
<Text size="lg" fw={600}>
{!isPersonalTeam && isTeamLeader ? t('settings.planBilling.tier.team', 'Team Plan') : getTierName()}
{!isPersonalTeam && isTeamLeader ? t("settings.planBilling.tier.team", "Team Plan") : getTierName()}
</Text>
{!isPersonalTeam && (
<Badge color="violet" variant="light" leftSection={<GroupIcon sx={{ fontSize: 12 }} />}>
{t('settings.planBilling.tier.teamBadge', 'Team')}
{t("settings.planBilling.tier.teamBadge", "Team")}
</Badge>
)}
<Tooltip
label={
<div style={{ maxWidth: 300 }}>
<Text size="sm" mb="xs">
{t('settings.planBilling.tier.teamTooltipCredits', {
{t("settings.planBilling.tier.teamTooltipCredits", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`,
})}
</Text>
<Text size="sm" mb="xs">
{t('settings.planBilling.tier.teamTooltipOverage', {
{t("settings.planBilling.tier.teamTooltipOverage", {
price: getFormattedOveragePrice(),
defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`,
})}
</Text>
<Text size="sm">
{t('settings.planBilling.tier.teamTooltipFineprint', 'Only pay for what you use beyond included credits.')}
{t(
"settings.planBilling.tier.teamTooltipFineprint",
"Only pay for what you use beyond included credits.",
)}
</Text>
</div>
}
@@ -143,18 +146,18 @@ export function ActiveSubscriptionCard({
</Tooltip>
{isTrialing && (
<Badge color="blue" variant="light">
{t('settings.planBilling.status.trial', 'Trial')}
{t("settings.planBilling.status.trial", "Trial")}
</Badge>
)}
</Group>
{!isPersonalTeam && !isTeamLeader && (
<Text size="sm" c="dimmed" mb="xs">
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
{t("settings.planBilling.team.managedByTeam", "Managed by team")}
</Text>
)}
{!isPersonalTeam && isTeamLeader && currentTeam && (
<Text size="sm" c="dimmed" mb="xs">
{t('settings.planBilling.team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })}
{t("settings.planBilling.team.memberCount", "{{count}} team members", { count: currentTeam.seatsUsed })}
</Text>
)}
<Text size="sm" c="dimmed" mb="xs">
@@ -169,10 +172,10 @@ export function ActiveSubscriptionCard({
</div>
{/* Right side: Price */}
<div style={{ textAlign: 'right' }}>
<div style={{ textAlign: "right" }}>
{!isPersonalTeam && !isTeamLeader ? (
<Text size="lg" c="dimmed">
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
{t("settings.planBilling.team.managedByTeam", "Managed by team")}
</Text>
) : (
<Text size="xl" fw={700}>
@@ -186,7 +189,8 @@ export function ActiveSubscriptionCard({
{subscription?.currentPeriodEnd && (
<Group gap="xs" mt="xs">
<Text size="sm" c="dimmed">
{t('settings.planBilling.billing.nextBillingDate', 'Next billing date:')} {formatDate(subscription.currentPeriodEnd)}
{t("settings.planBilling.billing.nextBillingDate", "Next billing date:")}{" "}
{formatDate(subscription.currentPeriodEnd)}
</Text>
</Group>
)}
@@ -209,7 +213,7 @@ export function ActiveSubscriptionCard({
{getDescription()}
</Text>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ textAlign: "right" }}>
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
@@ -1,11 +1,11 @@
import React from 'react';
import { Card, Text, Button, Stack, List, ThemeIcon } from '@mantine/core';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useTranslation } from 'react-i18next';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { BILLING_CONFIG } from '@app/config/billing';
import type { TierLevel } from '@app/types/billing';
import React from "react";
import { Card, Text, Button, Stack, List, ThemeIcon } from "@mantine/core";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { useTranslation } from "react-i18next";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { BILLING_CONFIG } from "@app/config/billing";
import type { TierLevel } from "@app/types/billing";
interface PlanUpgradeCardProps {
currentTier: TierLevel;
@@ -15,7 +15,7 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
const { t } = useTranslation();
// Don't show upgrade card if already on Team or Enterprise
if (currentTier !== 'free') {
if (currentTier !== "free") {
return null;
}
@@ -26,7 +26,7 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
try {
await shellOpen(upgradeUrl);
} catch (error) {
console.error('[PlanUpgradeCard] Failed to open upgrade URL:', error);
console.error("[PlanUpgradeCard] Failed to open upgrade URL:", error);
}
};
@@ -35,12 +35,12 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.upgrade.title', 'Upgrade Your Plan')}
{t("settings.planBilling.upgrade.title", "Upgrade Your Plan")}
</Text>
{/* Team plan benefits */}
<Text size="sm" c="dimmed">
{t('settings.planBilling.upgrade.subtitle', 'Upgrade to Team for:')}
{t("settings.planBilling.upgrade.subtitle", "Upgrade to Team for:")}
</Text>
<List
@@ -53,25 +53,25 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
}
>
<List.Item>
{t('settings.planBilling.upgrade.featureCredits', {
{t("settings.planBilling.upgrade.featureCredits", {
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month (vs ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} on Free)`,
})}
</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureMembers', 'Unlimited team members')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureThroughput', 'Faster processing throughput')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureApi', 'API access for automation')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureSupport', 'Priority support')}</List.Item>
<List.Item>{t("settings.planBilling.upgrade.featureMembers", "Unlimited team members")}</List.Item>
<List.Item>{t("settings.planBilling.upgrade.featureThroughput", "Faster processing throughput")}</List.Item>
<List.Item>{t("settings.planBilling.upgrade.featureApi", "API access for automation")}</List.Item>
<List.Item>{t("settings.planBilling.upgrade.featureSupport", "Priority support")}</List.Item>
</List>
{/* Upgrade button */}
<Button variant="filled" fullWidth onClick={handleUpgrade}>
{t('settings.planBilling.upgrade.cta', 'Upgrade to Team')}
{t("settings.planBilling.upgrade.cta", "Upgrade to Team")}
</Button>
<Text size="xs" c="dimmed" ta="center">
{t('settings.planBilling.upgrade.opensInBrowser', 'Opens in browser to complete upgrade')}
{t("settings.planBilling.upgrade.opensInBrowser", "Opens in browser to complete upgrade")}
</Text>
</Stack>
</Card>
@@ -1,10 +1,10 @@
import React from 'react';
import { Text, SimpleGrid, Loader, Alert, Center } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/useSaaSPlans';
import { SaasPlanCard } from '@app/components/shared/config/configSections/plan/SaasPlanCard';
import { useSaaSCheckout } from '@app/contexts/SaaSCheckoutContext';
import type { TierLevel } from '@app/types/billing';
import React from "react";
import { Text, SimpleGrid, Loader, Alert, Center } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/useSaaSPlans";
import { SaasPlanCard } from "@app/components/shared/config/configSections/plan/SaasPlanCard";
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
import type { TierLevel } from "@app/types/billing";
interface SaaSAvailablePlansSectionProps {
plans: PlanTier[];
@@ -17,7 +17,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
plans,
currentTier,
loading,
error
error,
}) => {
const { t } = useTranslation();
const { openCheckout } = useSaaSCheckout();
@@ -33,7 +33,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
return;
}
console.log('[SaaSAvailablePlansSection] Upgrade clicked for plan:', plan.id);
console.log("[SaaSAvailablePlansSection] Upgrade clicked for plan:", plan.id);
openCheckout(plan.id);
};
@@ -48,9 +48,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
if (error) {
return (
<Alert color="orange" variant="light" mt="md">
<Text size="sm">
{t('plan.availablePlans.loadError', 'Unable to load plan pricing. Using default values.')}
</Text>
<Text size="sm">{t("plan.availablePlans.loadError", "Unable to load plan pricing. Using default values.")}</Text>
</Alert>
);
}
@@ -58,10 +56,10 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
return (
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.availablePlans.title', 'Available Plans')}
{t("plan.availablePlans.title", "Available Plans")}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{plans.map(plan => (
{plans.map((plan) => (
<SaasPlanCard
key={plan.id}
plan={plan}
@@ -1,9 +1,9 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/useSaaSPlans';
import { FeatureListItem } from '@app/components/shared/modals/FeatureListItem';
import type { TierLevel } from '@app/types/billing';
import React from "react";
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/useSaaSPlans";
import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem";
import type { TierLevel } from "@app/types/billing";
interface SaasPlanCardProps {
plan: PlanTier;
@@ -12,30 +12,25 @@ interface SaasPlanCardProps {
onUpgradeClick?: (plan: PlanTier) => void;
}
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
plan,
isCurrentPlan,
currentTier,
onUpgradeClick
}) => {
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({ plan, isCurrentPlan, currentTier, onUpgradeClick }) => {
const { t } = useTranslation();
// Free plan is included if user has Team or Enterprise tier
const isIncluded = plan.id === 'free' && (currentTier === 'team' || currentTier === 'enterprise');
const isIncluded = plan.id === "free" && (currentTier === "team" || currentTier === "enterprise");
// Determine card styling based on plan type
const getCardStyle = () => {
const baseStyle: React.CSSProperties = {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
borderWidth: 1,
position: 'relative',
overflow: 'visible',
position: "relative",
overflow: "visible",
};
if (plan.id === 'free' && isCurrentPlan) {
if (plan.id === "free" && isCurrentPlan) {
return {
...baseStyle,
borderColor: 'var(--border-default)',
borderColor: "var(--border-default)",
opacity: 0.85,
};
}
@@ -43,11 +38,11 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
if (plan.popular) {
return {
...baseStyle,
borderColor: 'rgb(59, 130, 246)',
borderColor: "rgb(59, 130, 246)",
borderWidth: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.1)',
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
};
}
@@ -56,15 +51,15 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 12px 48px rgba(59, 130, 246, 0.3)';
e.currentTarget.style.transform = "translateY(-4px)";
e.currentTarget.style.boxShadow = "0 12px 48px rgba(59, 130, 246, 0.3)";
}
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.1)';
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 2px 8px rgba(59, 130, 246, 0.1)";
}
};
@@ -89,29 +84,31 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
<Badge
size="sm"
style={{
position: 'absolute',
position: "absolute",
top: -10,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgb(59, 130, 246)',
color: 'white',
left: "50%",
transform: "translateX(-50%)",
background: "rgb(59, 130, 246)",
color: "white",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
paddingLeft: '12px',
paddingRight: '12px',
textTransform: "uppercase",
letterSpacing: "0.5px",
paddingLeft: "12px",
paddingRight: "12px",
}}
>
{t('plan.popular', 'Popular')}
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="sm" className="h-full">
<div>
<Text size="md" fw={600} mb="xs">{plan.name}</Text>
<Text size="md" fw={600} mb="xs">
{plan.name}
</Text>
<Group gap="xs" align="baseline">
<Text size="xl" fw={700}>
{plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`}
{plan.isContactOnly ? t("plan.customPricing", "Custom") : `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
@@ -121,30 +118,28 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
</Group>
<Text size="xs" fw={400} c="dimmed">
{plan.isContactOnly
? t('plan.enterprise.siteLicense', 'Site License')
: plan.id === 'free'
? `50 ${t('credits.modal.monthlyCredits', 'monthly credits')}`
? t("plan.enterprise.siteLicense", "Site License")
: plan.id === "free"
? `50 ${t("credits.modal.monthlyCredits", "monthly credits")}`
: plan.overagePrice
? `500 ${t('credits.modal.monthlyCredits', 'monthly credits')} + ${plan.currency}${plan.overagePrice.toFixed(2)}/${t('credits.modal.overage', 'overage')}`
: `500 ${t('credits.modal.monthlyCredits', 'monthly credits')}`
}
? `500 ${t("credits.modal.monthlyCredits", "monthly credits")} + ${plan.currency}${plan.overagePrice.toFixed(2)}/${t("credits.modal.overage", "overage")}`
: `500 ${t("credits.modal.monthlyCredits", "monthly credits")}`}
</Text>
</div>
<Stack gap="xs">
<Text size="xs" fw={500} mb="xs">
{plan.id === 'free'
? t('credits.modal.forRegularWork', 'For regular PDF work:')
: plan.id === 'enterprise'
? t('credits.modal.everythingInCredits', 'Everything in Credits, plus:')
: t('credits.modal.everythingInFree', 'Everything in Free, plus:')
}
{plan.id === "free"
? t("credits.modal.forRegularWork", "For regular PDF work:")
: plan.id === "enterprise"
? t("credits.modal.everythingInCredits", "Everything in Credits, plus:")
: t("credits.modal.everythingInFree", "Everything in Free, plus:")}
</Text>
{plan.highlights.map((highlight: string, index: number) => (
<FeatureListItem
key={index}
included
color={plan.id === 'free' ? 'var(--mantine-color-gray-6)' : 'var(--color-primary-600)'}
color={plan.id === "free" ? "var(--mantine-color-gray-6)" : "var(--color-primary-600)"}
size="xs"
>
{highlight}
@@ -168,26 +163,25 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
style={{
fontWeight: 600,
...((isCurrentPlan || isIncluded) && {
background: 'transparent',
border: 'none',
cursor: 'default',
background: "transparent",
border: "none",
cursor: "default",
}),
...(plan.isContactOnly && {
borderColor: 'var(--text-primary)',
color: 'var(--text-primary)',
borderColor: "var(--text-primary)",
color: "var(--text-primary)",
}),
}}
component={plan.isContactOnly ? 'a' : undefined}
component={plan.isContactOnly ? "a" : undefined}
href={plan.isContactOnly ? `mailto:[email protected]?subject=${plan.name} Plan Inquiry` : undefined}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
? t("plan.current", "Current Plan")
: isIncluded
? t('plan.included', 'Included')
: plan.isContactOnly
? t('plan.contact', 'Contact Sales')
: t('plan.upgrade', 'Upgrade')
}
? t("plan.included", "Included")
: plan.isContactOnly
? t("plan.contact", "Contact Sales")
: t("plan.upgrade", "Upgrade")}
</Button>
</Stack>
</Card>
@@ -1,13 +1,13 @@
import React from 'react';
import { Card, Text, Stack, Group, Progress, Alert } from '@mantine/core';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { useTranslation } from 'react-i18next';
import type { BillingStatus } from '@app/services/saasBillingService';
import { BILLING_CONFIG, getFormattedOveragePrice } from '@app/config/billing';
import React from "react";
import { Card, Text, Stack, Group, Progress, Alert } from "@mantine/core";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { useTranslation } from "react-i18next";
import type { BillingStatus } from "@app/services/saasBillingService";
import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing";
interface UsageDisplayProps {
tier: BillingStatus['tier'];
usage: BillingStatus['meterUsage'];
tier: BillingStatus["tier"];
usage: BillingStatus["meterUsage"];
}
export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
@@ -16,11 +16,11 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
// Credits per month based on tier
const getMonthlyCredits = (): number => {
switch (tier) {
case 'free':
case "free":
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
case 'team':
case "team":
return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH;
case 'enterprise':
case "enterprise":
return 1000; // Placeholder — enterprise credits are custom
default:
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
@@ -39,13 +39,13 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.credits.title', 'Credit Usage')}
{t("settings.planBilling.credits.title", "Credit Usage")}
</Text>
{/* Monthly credits info */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('settings.planBilling.credits.included', {
{t("settings.planBilling.credits.included", {
count: monthlyCredits,
defaultValue: `${monthlyCredits} credits/month (included)`,
})}
@@ -58,13 +58,13 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('settings.planBilling.credits.overage', {
{t("settings.planBilling.credits.overage", {
count: usage.currentPeriodCredits,
defaultValue: `+ ${usage.currentPeriodCredits} overage`,
})}
</Text>
<Text size="sm" fw={500} c="orange">
{t('settings.planBilling.credits.estimatedCost', {
{t("settings.planBilling.credits.estimatedCost", {
amount: formatCurrency(usage.estimatedCost),
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
})}
@@ -72,19 +72,12 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
</Group>
{/* Progress bar for overage usage */}
<Progress
value={100}
color="orange"
size="sm"
radius="xl"
striped
animated
/>
<Progress value={100} color="orange" size="sm" radius="xl" striped animated />
</Stack>
<Alert color="blue" variant="light" icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}>
<Text size="xs">
{t('settings.planBilling.credits.overageInfo', {
{t("settings.planBilling.credits.overageInfo", {
price: getFormattedOveragePrice(),
defaultValue: `Overage credits are billed at ${getFormattedOveragePrice()} per credit. You'll only pay for what you use beyond your monthly allowance.`,
})}
@@ -94,10 +87,10 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
)}
{/* No overage message */}
{(!usage || usage.currentPeriodCredits === 0) && tier !== 'free' && (
{(!usage || usage.currentPeriodCredits === 0) && tier !== "free" && (
<Alert color="green" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.noOverage', {
{t("settings.planBilling.credits.noOverage", {
count: monthlyCredits,
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
})}
@@ -106,10 +99,10 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
)}
{/* Free tier message */}
{tier === 'free' && (
{tier === "free" && (
<Alert color="blue" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.freeTierInfo', {
{t("settings.planBilling.credits.freeTierInfo", {
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Free plan includes ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} credits per month. Upgrade to Team for ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month and pay-as-you-go overage billing.`,
@@ -1,9 +1,5 @@
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/types';
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
export const VALID_NAV_KEYS = [
...CORE_NAV_KEYS,
'connectionMode',
'planBilling',
] as const;
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "connectionMode", "planBilling"] as const;
export type NavKey = typeof VALID_NAV_KEYS[number];
export type NavKey = (typeof VALID_NAV_KEYS)[number];
@@ -1,15 +1,15 @@
import { Modal, Stack, Card, Text, Group, Badge, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { BILLING_CONFIG, getCurrencySymbol, getFormattedOveragePrice } from '@app/config/billing';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { CreditUsageBanner } from '@app/components/shared/modals/CreditUsageBanner';
import { FeatureListItem } from '@app/components/shared/modals/FeatureListItem';
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures';
import { useSaaSCheckout } from '@app/contexts/SaaSCheckoutContext';
import { useEnableMeteredBilling } from '@app/hooks/useEnableMeteredBilling';
import { Modal, Stack, Card, Text, Group, Badge, Button, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { BILLING_CONFIG, getCurrencySymbol, getFormattedOveragePrice } from "@app/config/billing";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { CreditUsageBanner } from "@app/components/shared/modals/CreditUsageBanner";
import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem";
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from "@app/config/planFeatures";
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling";
interface CreditExhaustedModalProps {
opened: boolean;
@@ -30,7 +30,7 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
refreshBilling,
onClose,
'CreditExhaustedModal'
"CreditExhaustedModal",
);
// Managed team members have unlimited credits via team
@@ -43,14 +43,17 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={t('credits.modal.managedMemberTitle', 'Unlimited Credits')}
title={t("credits.modal.managedMemberTitle", "Unlimited Credits")}
>
<Stack gap="md">
<Text size="sm">
{t('credits.modal.managedMemberMessage', 'You have unlimited access to credits through your team. If you need assistance, please contact your team leader.')}
{t(
"credits.modal.managedMemberMessage",
"You have unlimited access to credits through your team. If you need assistance, please contact your team leader.",
)}
</Text>
<Button onClick={onClose} fullWidth>
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Modal>
@@ -59,10 +62,9 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
// Team users should enable overage billing
// Only team leaders can enable metered billing, members see different UI
if (tier === 'team') {
const teamPlan = plans.get('team');
const teamCurrency = teamPlan?.currency ?? '$';
if (tier === "team") {
const teamPlan = plans.get("team");
const teamCurrency = teamPlan?.currency ?? "$";
const overagePrice = teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const formattedOveragePrice = getFormattedOveragePrice(teamCurrency, overagePrice);
@@ -80,31 +82,28 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{t('credits.modal.titleExhaustedPro', 'You have run out of credits')}
{t("credits.modal.titleExhaustedPro", "You have run out of credits")}
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.subtitlePro', 'Enable automatic overage billing to never run out of credits.')}
{t("credits.modal.subtitlePro", "Enable automatic overage billing to never run out of credits.")}
</Text>
</Stack>
}
styles={{
body: { padding: '0rem 0rem 0.5rem 0rem' },
body: { padding: "0rem 0rem 0.5rem 0rem" },
content: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
header: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
overlay: {
backgroundColor: 'light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))',
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}
/>
<CreditUsageBanner currentCredits={creditBalance} totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} />
{meteringError && (
<Alert color="red" ml="lg" mr="lg">
@@ -120,21 +119,21 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
ml="lg"
mr="lg"
style={{
borderColor: 'var(--color-primary-600)',
borderColor: "var(--color-primary-600)",
borderWidth: 2,
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md">
<Group gap="xs" align="center">
<TrendingUpIcon sx={{ fontSize: 24, color: 'var(--color-primary-600)' }} />
<TrendingUpIcon sx={{ fontSize: 24, color: "var(--color-primary-600)" }} />
<Text size="lg" fw={600}>
{t('credits.modal.meteringTitle', 'Pay-What-You-Use Overage Billing')}
{t("credits.modal.meteringTitle", "Pay-What-You-Use Overage Billing")}
</Text>
</Group>
<Text size="sm" c="dimmed">
{t('credits.modal.meteringExplanation', {
{t("credits.modal.meteringExplanation", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Your Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working.`,
})}
@@ -142,24 +141,24 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
<Stack gap="xs">
<FeatureListItem included>
{t('credits.modal.meteringIncluded', {
{t("credits.modal.meteringIncluded", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`,
})}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringPrice', 'Additional credits at {{price}}/credit', {
{t("credits.modal.meteringPrice", "Additional credits at {{price}}/credit", {
price: formattedOveragePrice,
})}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringPayAsYouGo', 'Only pay for what you use')}
{t("credits.modal.meteringPayAsYouGo", "Only pay for what you use")}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringNoCommitment', 'No commitment, cancel anytime')}
{t("credits.modal.meteringNoCommitment", "No commitment, cancel anytime")}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringNeverRunOut', 'Never run out of credits')}
{t("credits.modal.meteringNeverRunOut", "Never run out of credits")}
</FeatureListItem>
</Stack>
@@ -167,12 +166,15 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
padding="md"
radius="md"
style={{
backgroundColor: 'light-dark(#F8F9FA, #1A1A1E)',
border: '1px solid var(--border-subtle)',
backgroundColor: "light-dark(#F8F9FA, #1A1A1E)",
border: "1px solid var(--border-subtle)",
}}
>
<Text size="xs" c="dimmed">
{t('credits.modal.meteringBillingNote', 'Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.')}
{t(
"credits.modal.meteringBillingNote",
"Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.",
)}
</Text>
</Card>
</Stack>
@@ -193,15 +195,15 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
fontWeight: 600,
}}
>
{t('credits.enableOverageBilling', 'Enable Overage Billing')}
{t("credits.enableOverageBilling", "Enable Overage Billing")}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t('credits.modal.teamLeaderOnly', 'Only team leaders can enable overage billing')}
{t("credits.modal.teamLeaderOnly", "Only team leaders can enable overage billing")}
</Text>
)}
<Button onClick={onClose} variant="subtle" fullWidth size="md" c="dimmed" disabled={enablingMetering}>
{t('credits.maybeLater', 'Maybe later')}
{t("credits.maybeLater", "Maybe later")}
</Button>
</Stack>
</Stack>
@@ -210,9 +212,9 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
}
// Free tier users - show upgrade modal
const teamPlan = plans.get('team');
const teamPlan = plans.get("team");
const teamPrice = teamPlan?.price ?? 20;
const teamCurrency = teamPlan?.currency ?? '$';
const teamCurrency = teamPlan?.currency ?? "$";
const overagePrice = teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const currencySymbol = getCurrencySymbol(teamCurrency);
@@ -232,31 +234,28 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{t('credits.modal.titleExhausted', "You've used your free credits")}
{t("credits.modal.titleExhausted", "You've used your free credits")}
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.subtitle', 'Upgrade to Team for 10x the credits and faster processing.')}
{t("credits.modal.subtitle", "Upgrade to Team for 10x the credits and faster processing.")}
</Text>
</Stack>
}
styles={{
body: { padding: '0rem 0rem 0.5rem 0rem' },
body: { padding: "0rem 0rem 0.5rem 0rem" },
content: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
header: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
overlay: {
backgroundColor: 'light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))',
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH}
/>
<CreditUsageBanner currentCredits={creditBalance} totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH} />
<Group gap="md" ml="lg" mr="lg" align="stretch" grow>
{/* Free Plan Card */}
@@ -265,33 +264,33 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
radius="md"
withBorder
style={{
borderColor: 'var(--border-default)',
borderColor: "var(--border-default)",
borderWidth: 1,
opacity: 0.85,
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md" style={{ height: '100%' }}>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t('credits.modal.freeTier', 'Free Tier')}
{t("credits.modal.freeTier", "Free Tier")}
</Text>
<Group gap="xs" align="baseline">
<Text size="1.75rem" fw={700}>
{currencySymbol}0
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.perMonth', '/month')}
{t("credits.modal.perMonth", "/month")}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.FREE_CREDITS_PER_MONTH} {t('credits.modal.monthlyCredits', 'monthly credits')}
{BILLING_CONFIG.FREE_CREDITS_PER_MONTH} {t("credits.modal.monthlyCredits", "monthly credits")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t('credits.modal.forRegularWork', 'For regular PDF work:')}
{t("credits.modal.forRegularWork", "For regular PDF work:")}
</Text>
{FREE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included color="var(--mantine-color-gray-6)">
@@ -308,12 +307,12 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
radius="lg"
style={{
fontWeight: 600,
background: 'transparent',
border: 'none',
cursor: 'default',
background: "transparent",
border: "none",
cursor: "default",
}}
>
{t('credits.modal.current', 'Current Plan')}
{t("credits.modal.current", "Current Plan")}
</Button>
</Stack>
</Card>
@@ -324,65 +323,66 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
radius="md"
withBorder
style={{
borderColor: 'var(--card-selected-border)',
borderColor: "var(--card-selected-border)",
borderWidth: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
position: 'relative',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.1)',
overflow: 'visible',
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
cursor: "pointer",
transition: "all 0.2s ease",
position: "relative",
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
overflow: "visible",
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
onClick={() => openCheckout('pro')}
onClick={() => openCheckout("pro")}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 12px 48px rgba(59, 130, 246, 0.3)';
e.currentTarget.style.transform = "translateY(-4px)";
e.currentTarget.style.boxShadow = "0 12px 48px rgba(59, 130, 246, 0.3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.1)';
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 2px 8px rgba(59, 130, 246, 0.1)";
}}
>
<Badge
size="sm"
style={{
position: 'absolute',
position: "absolute",
top: -10,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgb(59, 130, 246)',
color: 'white',
left: "50%",
transform: "translateX(-50%)",
background: "rgb(59, 130, 246)",
color: "white",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
paddingLeft: '12px',
paddingRight: '12px',
textTransform: "uppercase",
letterSpacing: "0.5px",
paddingLeft: "12px",
paddingRight: "12px",
}}
>
{t('credits.modal.popular', 'Popular')}
{t("credits.modal.popular", "Popular")}
</Badge>
<Stack gap="md" style={{ height: '100%' }}>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t('credits.modal.teamSubscription', 'Team')}
{t("credits.modal.teamSubscription", "Team")}
</Text>
<Group gap="xs" align="baseline" mt="xs">
<Text size="1.75rem" fw={700} style={{ color: 'var(--text-primary)' }}>
<Text size="1.75rem" fw={700} style={{ color: "var(--text-primary)" }}>
{currencySymbol}
{teamPrice}
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.perMonth', '/month')}
{t("credits.modal.perMonth", "/month")}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} {t('credits.modal.monthlyCredits', 'monthly credits')} + {formattedOveragePrice}/{t('credits.modal.overage', 'overage')}
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} {t("credits.modal.monthlyCredits", "monthly credits")} +{" "}
{formattedOveragePrice}/{t("credits.modal.overage", "overage")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t('credits.modal.everythingInFree', 'Everything in Free, plus:')}
{t("credits.modal.everythingInFree", "Everything in Free, plus:")}
</Text>
{TEAM_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
@@ -392,7 +392,7 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
</Stack>
<Button
onClick={() => openCheckout('pro')}
onClick={() => openCheckout("pro")}
variant="filled"
color="blue"
fullWidth
@@ -402,7 +402,7 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
fontWeight: 600,
}}
>
{t('credits.upgrade', 'Upgrade')}
{t("credits.upgrade", "Upgrade")}
</Button>
</Stack>
</Card>
@@ -414,25 +414,25 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
withBorder
style={{
borderWidth: 1,
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md" style={{ height: '100%' }}>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="md" fw={600} mb="xs">
{t('credits.modal.enterpriseSubscription', 'Enterprise')}
{t("credits.modal.enterpriseSubscription", "Enterprise")}
</Text>
<Text size="1.75rem" fw={600}>
{t('credits.modal.customPricing', 'Custom')}
{t("credits.modal.customPricing", "Custom")}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('credits.modal.unlimitedMonthlyCredits', 'Site License')}
{t("credits.modal.unlimitedMonthlyCredits", "Site License")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t('credits.modal.everythingInCredits', 'Everything in Credits, plus:')}
{t("credits.modal.everythingInCredits", "Everything in Credits, plus:")}
</Text>
{ENTERPRISE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
@@ -449,34 +449,34 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
size="md"
radius="lg"
style={{
borderColor: 'var(--text-primary)',
color: 'var(--text-primary)',
borderColor: "var(--text-primary)",
color: "var(--text-primary)",
fontWeight: 600,
}}
>
{t('credits.modal.contactSales', 'Contact Sales')}
{t("credits.modal.contactSales", "Contact Sales")}
</Button>
</Stack>
</Card>
</Group>
<Text size="sm" ta="center" c="dimmed" mt="md">
{t('credits.modal.selfHostPrompt', 'Want to self host?')}{' '}
{t("credits.modal.selfHostPrompt", "Want to self host?")}{" "}
<Text
component="a"
href="https://www.stirling.com/pricing"
target="_blank"
rel="noopener noreferrer"
size="sm"
style={{ color: 'var(--mantine-color-blue-6)', textDecoration: 'none' }}
style={{ color: "var(--mantine-color-blue-6)", textDecoration: "none" }}
onMouseEnter={(e) => {
e.currentTarget.style.textDecoration = 'underline';
e.currentTarget.style.textDecoration = "underline";
}}
onMouseLeave={(e) => {
e.currentTarget.style.textDecoration = 'none';
e.currentTarget.style.textDecoration = "none";
}}
>
{t('credits.modal.selfHostLink', 'Review the docs and plans')}
{t("credits.modal.selfHostLink", "Review the docs and plans")}
</Text>
</Text>
</Stack>
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSMode } from '@app/hooks/useSaaSMode';
import { BILLING_CONFIG } from '@app/config/billing';
import { CreditExhaustedModal } from '@app/components/shared/modals/CreditExhaustedModal';
import { InsufficientCreditsModal } from '@app/components/shared/modals/InsufficientCreditsModal';
import { useCreditEvents } from '@app/hooks/useCreditEvents';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
import { useEffect, useState } from "react";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { BILLING_CONFIG } from "@app/config/billing";
import { CreditExhaustedModal } from "@app/components/shared/modals/CreditExhaustedModal";
import { InsufficientCreditsModal } from "@app/components/shared/modals/InsufficientCreditsModal";
import { useCreditEvents } from "@app/hooks/useCreditEvents";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
/**
* Desktop Credit Modal Bootstrap
@@ -27,7 +27,13 @@ export function CreditModalBootstrap() {
// Fires once: only when in SaaS mode, billing has loaded (lastFetchTime set) and plans haven't been
// fetched yet (plansLastFetchTime null). This way the modal shows real prices instantly.
useEffect(() => {
if (isSaaSMode && lastFetchTime !== null && plansLastFetchTime === null && creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD && !isManagedTeamMember) {
if (
isSaaSMode &&
lastFetchTime !== null &&
plansLastFetchTime === null &&
creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD &&
!isManagedTeamMember
) {
refreshPlans();
}
}, [isSaaSMode, lastFetchTime, plansLastFetchTime, creditBalance, isManagedTeamMember, refreshPlans]);
@@ -70,10 +76,7 @@ export function CreditModalBootstrap() {
return (
<>
<CreditExhaustedModal
opened={exhaustedOpen && !insufficientOpen}
onClose={() => setExhaustedOpen(false)}
/>
<CreditExhaustedModal opened={exhaustedOpen && !insufficientOpen} onClose={() => setExhaustedOpen(false)} />
<InsufficientCreditsModal
opened={insufficientOpen}
onClose={() => setInsufficientOpen(false)}
@@ -1,5 +1,5 @@
import { Divider, Group, Text, Progress, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Divider, Group, Text, Progress, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface CreditUsageBannerProps {
currentCredits: number;
@@ -20,10 +20,10 @@ export function CreditUsageBanner({ currentCredits, totalCredits }: CreditUsageB
<Stack gap="xs" pr="md" pl="md">
<Group gap="xs" justify="space-between" align="center">
<Text size="md" fw={400} c="dimmed">
{t('credits.modal.creditsThisMonth', 'Monthly credits')}
{t("credits.modal.creditsThisMonth", "Monthly credits")}
</Text>
<Text size="md" fw={600} style={{ color: 'var(--text-primary)' }}>
{t('credits.modal.creditsRemaining', '{{current}} of {{total}} remaining', {
<Text size="md" fw={600} style={{ color: "var(--text-primary)" }}>
{t("credits.modal.creditsRemaining", "{{current}} of {{total}} remaining", {
current: currentCredits,
total: totalCredits,
})}
@@ -35,7 +35,7 @@ export function CreditUsageBanner({ currentCredits, totalCredits }: CreditUsageB
radius="xl"
color="blue"
styles={{
root: { backgroundColor: 'var(--bg-raised)' },
root: { backgroundColor: "var(--bg-raised)" },
}}
/>
</Stack>
@@ -1,6 +1,6 @@
import { Group, Text } from '@mantine/core';
import CheckCircleIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import { Group, Text } from "@mantine/core";
import CheckCircleIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
interface FeatureListItemProps {
children: React.ReactNode;
@@ -8,26 +8,26 @@ interface FeatureListItemProps {
color?: string;
dimmed?: boolean;
fw?: number;
size?: 'xs' | 'sm' | 'md' | 'lg' | string;
size?: "xs" | "sm" | "md" | "lg" | string;
}
export function FeatureListItem({
children,
included,
color = 'var(--color-primary-600)',
color = "var(--color-primary-600)",
dimmed = false,
fw = 400,
size = 'sm'
size = "sm",
}: FeatureListItemProps) {
const Icon = included ? CheckCircleIcon : CloseIcon;
const iconColor = included ? color : 'var(--color-red-600)';
const iconColor = included ? color : "var(--color-red-600)";
// Map Mantine sizes to icon font sizes
const iconSizeMap: Record<string, number> = {
xs: 14,
sm: 16,
md: 18,
lg: 20
lg: 20,
};
// Determine icon size - use mapped value if it exists, otherwise use the string directly
@@ -38,10 +38,8 @@ export function FeatureListItem({
return (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Icon
sx={{ fontSize: iconSize, color: iconColor, flexShrink: 0, marginTop: '2px' }}
/>
<Text size={textSize} c={dimmed ? 'dimmed' : undefined} fw={fw} style={textSize ? undefined : { fontSize: size }}>
<Icon sx={{ fontSize: iconSize, color: iconColor, flexShrink: 0, marginTop: "2px" }} />
<Text size={textSize} c={dimmed ? "dimmed" : undefined} fw={fw} style={textSize ? undefined : { fontSize: size }}>
{children}
</Text>
</Group>
@@ -1,11 +1,11 @@
import { Modal, Stack, Text, Button, Alert, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useSaaSCheckout } from '@app/contexts/SaaSCheckoutContext';
import WarningIcon from '@mui/icons-material/Warning';
import { useEnableMeteredBilling } from '@app/hooks/useEnableMeteredBilling';
import { Modal, Stack, Text, Button, Alert, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
import WarningIcon from "@mui/icons-material/Warning";
import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling";
interface InsufficientCreditsModalProps {
opened: boolean;
@@ -18,12 +18,7 @@ interface InsufficientCreditsModalProps {
* Desktop Insufficient Credits Modal
* Shows when user attempts operation without enough credits
*/
export function InsufficientCreditsModal({
opened,
onClose,
toolId,
requiredCredits,
}: InsufficientCreditsModalProps) {
export function InsufficientCreditsModal({ opened, onClose, toolId, requiredCredits }: InsufficientCreditsModalProps) {
const { t } = useTranslation();
const { creditBalance, tier, refreshBilling, isManagedTeamMember } = useSaaSBilling();
const { isTeamLeader } = useSaaSTeam();
@@ -32,10 +27,10 @@ export function InsufficientCreditsModal({
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
refreshBilling,
onClose,
'InsufficientCreditsModal'
"InsufficientCreditsModal",
);
const toolName = toolId ? t(`tool.${toolId}.name`, toolId) : t('common.operation', 'this operation');
const toolName = toolId ? t(`tool.${toolId}.name`, toolId) : t("common.operation", "this operation");
return (
<Modal
@@ -47,9 +42,9 @@ export function InsufficientCreditsModal({
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Group gap="xs">
<WarningIcon sx={{ fontSize: 24, color: 'var(--mantine-color-orange-6)' }} />
<WarningIcon sx={{ fontSize: 24, color: "var(--mantine-color-orange-6)" }} />
<Text size="lg" fw={500}>
{t('credits.insufficient.title', 'Insufficient Credits')}
{t("credits.insufficient.title", "Insufficient Credits")}
</Text>
</Group>
}
@@ -59,21 +54,21 @@ export function InsufficientCreditsModal({
<Text size="sm">
{requiredCredits
? t(
'credits.insufficient.messageWithAmount',
'You need {{required}} credits to run {{tool}}, but you only have {{current}}.',
"credits.insufficient.messageWithAmount",
"You need {{required}} credits to run {{tool}}, but you only have {{current}}.",
{
required: requiredCredits,
tool: toolName,
current: creditBalance,
}
},
)
: t(
'credits.insufficient.message',
'You do not have enough credits to run {{tool}}. You currently have {{current}} credits.',
"credits.insufficient.message",
"You do not have enough credits to run {{tool}}. You currently have {{current}} credits.",
{
tool: toolName,
current: creditBalance,
}
},
)}
</Text>
</Alert>
@@ -81,28 +76,18 @@ export function InsufficientCreditsModal({
{isManagedTeamMember ? (
<>
<Text size="sm" c="dimmed">
{t(
'credits.insufficient.managedMember',
'Please contact your team leader for assistance.'
)}
{t("credits.insufficient.managedMember", "Please contact your team leader for assistance.")}
</Text>
<Button onClick={onClose} fullWidth>
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</>
) : tier === 'team' ? (
) : tier === "team" ? (
<>
<Text size="sm" c="dimmed">
{t(
'credits.insufficient.teamMember',
'Enable overage billing to never run out of credits.'
)}
{t("credits.insufficient.teamMember", "Enable overage billing to never run out of credits.")}
</Text>
{meteringError && (
<Alert color="red">
{meteringError}
</Alert>
)}
{meteringError && <Alert color="red">{meteringError}</Alert>}
<Button
variant="filled"
color="blue"
@@ -111,38 +96,35 @@ export function InsufficientCreditsModal({
loading={enablingMetering}
disabled={!isTeamLeader}
>
{t('credits.enableOverageBilling', 'Enable Overage Billing')}
{t("credits.enableOverageBilling", "Enable Overage Billing")}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t('credits.modal.teamLeaderOnly', 'Only team leaders can enable overage billing')}
{t("credits.modal.teamLeaderOnly", "Only team leaders can enable overage billing")}
</Text>
)}
<Button onClick={onClose} variant="subtle" fullWidth disabled={enablingMetering}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
</>
) : (
<>
<Text size="sm" c="dimmed">
{t(
'credits.insufficient.freeTier',
'Upgrade to Team for 10x more credits and unlimited overage billing.'
)}
{t("credits.insufficient.freeTier", "Upgrade to Team for 10x more credits and unlimited overage billing.")}
</Text>
<Button
variant="filled"
color="blue"
fullWidth
onClick={() => {
openCheckout('pro');
openCheckout("pro");
onClose();
}}
>
{t('credits.upgrade', 'Upgrade to Team')}
{t("credits.upgrade", "Upgrade to Team")}
</Button>
<Button onClick={onClose} variant="subtle" fullWidth>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
</>
)}
@@ -1,11 +1,11 @@
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';
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>;
@@ -31,7 +31,7 @@ const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
props.id as string,
props.tool as ToolRegistryEntry,
toolAvailability,
premiumEnabled
premiumEnabled,
);
// In local mode, pass a handler so CoreToolButton renders the tool as "cloud-available"
@@ -39,9 +39,7 @@ const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
// 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'
connectionMode === "local" && disabledReason !== "comingSoon" && disabledReason !== "selfHostedOffline"
? () => handleToolSelectForced(props.id as ToolId)
: undefined;
@@ -1,8 +1,8 @@
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';
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.
@@ -21,7 +21,7 @@ export function ToolPickerFooterExtensions() {
return unsubscribe;
}, []);
if (connectionMode !== 'local') return null;
if (connectionMode !== "local") return null;
return (
<Group
@@ -32,13 +32,13 @@ export function ToolPickerFooterExtensions() {
px="sm"
py={10}
style={{
borderTop: '1px solid var(--border-default)',
background: 'var(--bg-toolbar)',
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.')}
{t("localMode.toolPicker.message", "Sign in to unlock all tools.")}
</Text>
<Button
size="compact-xs"
@@ -47,7 +47,7 @@ export function ToolPickerFooterExtensions() {
style={{ flexShrink: 0 }}
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))}
>
{t('localMode.toolPicker.signIn', 'Sign In')}
{t("localMode.toolPicker.signIn", "Sign In")}
</Button>
</Group>
);
@@ -1,9 +1,9 @@
import { useEffect } from 'react';
import { usePrintCapability } from '@embedpdf/plugin-print/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
import { printPdfNatively } from '@app/services/nativePrintService';
import { DesktopOs, getDesktopOs } from '@app/services/platformService';
import { useEffect } from "react";
import { usePrintCapability } from "@embedpdf/plugin-print/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady";
import { printPdfNatively } from "@app/services/nativePrintService";
import { DesktopOs, getDesktopOs } from "@app/services/platformService";
import { PrintAPIBridgeProps } from "@core/components/viewer/PrintAPIBridge";
export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
@@ -13,7 +13,7 @@ export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
useEffect(() => {
if (documentReady) {
registerBridge('print', {
registerBridge("print", {
state: {},
api: {
print: () => {
@@ -28,15 +28,15 @@ export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
print?.print?.();
})().catch((error) => {
console.error('[Desktop Print] Print failed', error);
console.error("[Desktop Print] Print failed", error);
});
},
}
},
});
}
return () => {
registerBridge('print', null);
registerBridge("print", null);
};
}, [documentReady, file, fileName, print, registerBridge, url]);
+18 -14
View File
@@ -21,20 +21,20 @@ export const BILLING_CONFIG = {
PLAN_PRICING_PRELOAD_THRESHOLD: 20,
// Stripe lookup keys
PRO_PLAN_LOOKUP_KEY: 'plan:pro',
METER_LOOKUP_KEY: 'meter:overage',
PRO_PLAN_LOOKUP_KEY: "plan:pro",
METER_LOOKUP_KEY: "meter:overage",
// Display formats
CURRENCY_SYMBOLS: {
gbp: '£',
usd: '$',
eur: '€',
cny: '¥',
inr: '₹',
brl: 'R$',
idr: 'Rp',
jpy: '¥'
} as const
gbp: "£",
usd: "$",
eur: "€",
cny: "¥",
inr: "₹",
brl: "R$",
idr: "Rp",
jpy: "¥",
} as const,
} as const;
/**
@@ -49,8 +49,9 @@ export function getBillingConfig() {
* @param currency Currency code (e.g., 'usd', 'gbp')
* @param price Optional price override (defaults to BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT)
*/
export function getFormattedOveragePrice(currency: string = 'usd', price?: number): string {
const symbol = BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || '$';
export function getFormattedOveragePrice(currency: string = "usd", price?: number): string {
const symbol =
BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || "$";
const amount = price ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
return `${symbol}${amount.toFixed(2)}`;
}
@@ -59,5 +60,8 @@ export function getFormattedOveragePrice(currency: string = 'usd', price?: numbe
* Get currency symbol from currency code
*/
export function getCurrencySymbol(currency: string): string {
return BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || currency.toUpperCase();
return (
BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] ||
currency.toUpperCase()
);
}
@@ -1,4 +1,4 @@
import { AppConfig } from '@app/contexts/AppConfigContext';
import { AppConfig } from "@app/contexts/AppConfigContext";
/**
* Default configuration used while the bundled backend starts up.
+37 -37
View File
@@ -10,77 +10,77 @@ export interface PlanFeatureConfig {
export const FREE_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: 'credits.modal.allInOneWorkspace',
defaultText: 'All-in-one PDF workspace (viewer, tools & agent)'
translationKey: "credits.modal.allInOneWorkspace",
defaultText: "All-in-one PDF workspace (viewer, tools & agent)",
},
{
translationKey: 'credits.modal.fullyPrivateFiles',
defaultText: 'Fully private files'
translationKey: "credits.modal.fullyPrivateFiles",
defaultText: "Fully private files",
},
{
translationKey: 'credits.modal.standardThroughput',
defaultText: 'Standard throughput'
translationKey: "credits.modal.standardThroughput",
defaultText: "Standard throughput",
},
{
translationKey: 'credits.modal.customSmartFolders',
defaultText: 'Custom Smart Folders'
translationKey: "credits.modal.customSmartFolders",
defaultText: "Custom Smart Folders",
},
{
translationKey: 'credits.modal.apiSandbox',
defaultText: 'API sandbox'
}
translationKey: "credits.modal.apiSandbox",
defaultText: "API sandbox",
},
];
export const TEAM_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: 'credits.modal.unlimitedSeats',
defaultText: 'Unlimited seats'
translationKey: "credits.modal.unlimitedSeats",
defaultText: "Unlimited seats",
},
{
translationKey: 'credits.modal.fasterThroughput',
defaultText: '10x faster throughput'
translationKey: "credits.modal.fasterThroughput",
defaultText: "10x faster throughput",
},
{
translationKey: 'credits.modal.largeFileProcessing',
defaultText: 'Large file processing'
translationKey: "credits.modal.largeFileProcessing",
defaultText: "Large file processing",
},
{
translationKey: 'credits.modal.premiumAiModels',
defaultText: 'Premium AI models'
translationKey: "credits.modal.premiumAiModels",
defaultText: "Premium AI models",
},
{
translationKey: 'credits.modal.secureApiAccess',
defaultText: 'Secure API access'
translationKey: "credits.modal.secureApiAccess",
defaultText: "Secure API access",
},
{
translationKey: 'credits.modal.prioritySupport',
defaultText: 'Priority support'
}
translationKey: "credits.modal.prioritySupport",
defaultText: "Priority support",
},
];
export const ENTERPRISE_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: 'credits.modal.orgWideAccess',
defaultText: 'Org-wide access controls'
translationKey: "credits.modal.orgWideAccess",
defaultText: "Org-wide access controls",
},
{
translationKey: 'credits.modal.privateDocCloud',
defaultText: 'Private Document Cloud'
translationKey: "credits.modal.privateDocCloud",
defaultText: "Private Document Cloud",
},
{
translationKey: 'credits.modal.ragFineTuning',
defaultText: 'RAG + fine-tuning'
translationKey: "credits.modal.ragFineTuning",
defaultText: "RAG + fine-tuning",
},
{
translationKey: 'credits.modal.unlimitedApiAccess',
defaultText: 'Unlimited API access'
translationKey: "credits.modal.unlimitedApiAccess",
defaultText: "Unlimited API access",
},
{
translationKey: 'credits.modal.advancedMonitoring',
defaultText: 'Advanced monitoring'
translationKey: "credits.modal.advancedMonitoring",
defaultText: "Advanced monitoring",
},
{
translationKey: 'credits.modal.dedicatedSupportSlas',
defaultText: 'Dedicated support & SLAs'
}
translationKey: "credits.modal.dedicatedSupportSlas",
defaultText: "Dedicated support & SLAs",
},
];
@@ -1,20 +1,22 @@
import i18n from '@app/i18n';
import i18n from "@app/i18n";
export const BACKEND_NOT_READY_CODE = 'BACKEND_NOT_READY' as const;
export const BACKEND_NOT_READY_CODE = "BACKEND_NOT_READY" as const;
export interface BackendNotReadyError extends Error {
code: typeof BACKEND_NOT_READY_CODE;
}
export function createBackendNotReadyError(): BackendNotReadyError {
return Object.assign(new Error(i18n.t('backendHealth.starting', 'Backend starting up...')), {
return Object.assign(new Error(i18n.t("backendHealth.starting", "Backend starting up...")), {
code: BACKEND_NOT_READY_CODE,
});
}
export function isBackendNotReadyError(error: unknown): error is BackendNotReadyError {
return typeof error === 'object'
&& error !== null
&& 'code' in error
&& (error as { code?: unknown }).code === BACKEND_NOT_READY_CODE;
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === BACKEND_NOT_READY_CODE
);
}
+1 -1
View File
@@ -12,4 +12,4 @@ export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env.VITE_SAAS_B
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
// Desktop deep link callback for Supabase email confirmations
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
export const DESKTOP_DEEP_LINK_CALLBACK = "stirlingpdf://auth/callback";
@@ -4,9 +4,9 @@
*/
export const CREDIT_EVENTS = {
EXHAUSTED: 'credits:exhausted',
INSUFFICIENT: 'credits:insufficient',
REFRESH_NEEDED: 'credits:refresh-needed',
EXHAUSTED: "credits:exhausted",
INSUFFICIENT: "credits:insufficient",
REFRESH_NEEDED: "credits:refresh-needed",
} as const;
export type CreditEventType = typeof CREDIT_EVENTS[keyof typeof CREDIT_EVENTS];
export type CreditEventType = (typeof CREDIT_EVENTS)[keyof typeof CREDIT_EVENTS];
@@ -2,4 +2,4 @@
* 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';
export const OPEN_SIGN_IN_EVENT = "stirling:open-sign-in";
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
import { SaaSStripeCheckout } from '@app/components/shared/billing/SaaSStripeCheckout';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
import { SaaSStripeCheckout } from "@app/components/shared/billing/SaaSStripeCheckout";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
interface SaaSCheckoutContextType {
opened: boolean;
@@ -14,7 +14,7 @@ const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(u
export const useSaaSCheckout = () => {
const context = useContext(SaaSCheckoutContext);
if (!context) {
throw new Error('useSaaSCheckout must be used within SaaSCheckoutProvider');
throw new Error("useSaaSCheckout must be used within SaaSCheckoutProvider");
}
return context;
};
@@ -23,9 +23,7 @@ interface SaaSCheckoutProviderProps {
children: ReactNode;
}
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
children
}) => {
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({ children }) => {
const [opened, setOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
@@ -46,11 +44,11 @@ export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
// Internal success handler - automatically refreshes billing context
const handleCheckoutSuccess = useCallback(async () => {
// Wait for webhooks to process (2 seconds)
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
await refreshBilling();
} catch (error) {
console.error('[SaaSCheckoutContext] Failed to refresh billing after checkout:', error);
console.error("[SaaSCheckoutContext] Failed to refresh billing after checkout:", error);
}
}, [refreshBilling]);
@@ -64,12 +62,7 @@ export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
}}
>
{children}
<SaaSStripeCheckout
opened={opened}
onClose={closeCheckout}
planId={selectedPlan}
onSuccess={handleCheckoutSuccess}
/>
<SaaSStripeCheckout opened={opened} onClose={closeCheckout} planId={selectedPlan} onSuccess={handleCheckoutSuccess} />
</SaaSCheckoutContext.Provider>
);
};
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react";
import apiClient from "@app/services/apiClient";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
/**
* Desktop implementation of SaaS Team Context
@@ -91,7 +91,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const checkAccess = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === 'saas');
setIsSaasMode(mode === "saas");
setIsAuthenticated(auth);
};
@@ -105,7 +105,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
@@ -113,16 +113,16 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const fetchMyTeams = useCallback(async () => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
console.log('[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated');
console.log("[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated");
return null;
}
try {
const response = await apiClient.get<Team[]>('/api/v1/team/my', { suppressErrorToast: true });
const response = await apiClient.get<Team[]>("/api/v1/team/my", { suppressErrorToast: true });
setTeams(response.data);
const activeTeam = response.data[0];
console.log('[SaaSTeamContext] Current team set:', {
console.log("[SaaSTeamContext] Current team set:", {
teamId: activeTeam?.teamId,
name: activeTeam?.name,
isPersonal: activeTeam?.isPersonal,
@@ -131,39 +131,47 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
setCurrentTeam(activeTeam || null);
return activeTeam || null;
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch teams:', error);
console.error("[SaaSTeamContext] Failed to fetch teams:", error);
return null;
}
}, [isSaasMode, isAuthenticated]);
const fetchTeamMembers = useCallback(async (teamId: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
console.log('[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated');
return;
}
const fetchTeamMembers = useCallback(
async (teamId: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
console.log("[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated");
return;
}
try {
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`, { suppressErrorToast: true });
setTeamMembers(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
}
}, [isSaasMode, isAuthenticated]);
try {
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`, { suppressErrorToast: true });
setTeamMembers(response.data);
} catch (error) {
console.error("[SaaSTeamContext] Failed to fetch team members:", error);
}
},
[isSaasMode, isAuthenticated],
);
const fetchTeamInvitations = useCallback(async (teamId?: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated || !teamId) {
return;
}
const fetchTeamInvitations = useCallback(
async (teamId?: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated || !teamId) {
return;
}
try {
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`, { suppressErrorToast: true });
setTeamInvitations(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch team invitations:', error);
}
}, [isSaasMode, isAuthenticated]);
try {
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`, {
suppressErrorToast: true,
});
setTeamInvitations(response.data);
} catch (error) {
console.error("[SaaSTeamContext] Failed to fetch team invitations:", error);
}
},
[isSaasMode, isAuthenticated],
);
const fetchReceivedInvitations = useCallback(async () => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
@@ -171,14 +179,14 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
return;
}
console.log('[SaaSTeamContext] Fetching received team invitations');
console.log("[SaaSTeamContext] Fetching received team invitations");
try {
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending', { suppressErrorToast: true });
console.log('[SaaSTeamContext] Received invitations response:', response.data);
const response = await apiClient.get<TeamInvitation[]>("/api/v1/team/invitations/pending", { suppressErrorToast: true });
console.log("[SaaSTeamContext] Received invitations response:", response.data);
setReceivedInvitations(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch received invitations:', error);
console.error("[SaaSTeamContext] Failed to fetch received invitations:", error);
}
}, [isSaasMode, isAuthenticated]);
@@ -214,18 +222,18 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
}, [currentTeam, isSaasMode, isAuthenticated, fetchTeamMembers, fetchTeamInvitations]);
const inviteUser = async (email: string) => {
if (!currentTeam) throw new Error('No current team');
if (!isSaasMode) throw new Error('Not in SaaS mode');
if (!currentTeam) throw new Error("No current team");
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post('/api/v1/team/invite', {
await apiClient.post("/api/v1/team/invite", {
teamId: currentTeam.teamId,
email
email,
});
await fetchTeamInvitations(currentTeam.teamId);
};
const acceptInvitation = async (token: string) => {
if (!isSaasMode) throw new Error('Not in SaaS mode');
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
await fetchReceivedInvitations();
@@ -234,14 +242,14 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
};
const rejectInvitation = async (token: string) => {
if (!isSaasMode) throw new Error('Not in SaaS mode');
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
await fetchReceivedInvitations();
};
const cancelInvitation = async (invitationId: number) => {
if (!isSaasMode) throw new Error('Not in SaaS mode');
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
if (currentTeam) {
@@ -250,8 +258,8 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
};
const removeMember = async (memberId: number) => {
if (!currentTeam) throw new Error('No current team');
if (!isSaasMode) throw new Error('Not in SaaS mode');
if (!currentTeam) throw new Error("No current team");
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.delete(`/api/v1/team/${currentTeam.teamId}/members/${memberId}`);
await refreshTeams();
@@ -259,8 +267,8 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
};
const leaveTeam = async () => {
if (!currentTeam) throw new Error('No current team');
if (!isSaasMode) throw new Error('Not in SaaS mode');
if (!currentTeam) throw new Error("No current team");
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
await refreshTeams();
@@ -269,7 +277,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const refreshTeams = useCallback(async () => {
if (!isSaasMode || !isAuthenticated) {
console.log('[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated');
console.log("[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated");
return;
}
@@ -288,23 +296,25 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const isPersonalTeam = currentTeam?.isPersonal ?? true;
return (
<SaaSTeamContext.Provider value={{
currentTeam,
teams,
teamMembers,
teamInvitations,
receivedInvitations,
isTeamLeader,
isPersonalTeam,
loading,
inviteUser,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams
}}>
<SaaSTeamContext.Provider
value={{
currentTeam,
teams,
teamMembers,
teamInvitations,
receivedInvitations,
isTeamLeader,
isPersonalTeam,
loading,
inviteUser,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
}}
>
{children}
</SaaSTeamContext.Provider>
);
@@ -313,7 +323,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
export function useSaaSTeam() {
const context = useContext(SaaSTeamContext);
if (context === undefined) {
throw new Error('useSaaSTeam must be used within a SaaSTeamProvider');
throw new Error("useSaaSTeam must be used within a SaaSTeamProvider");
}
return context;
}
@@ -1,9 +1,9 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } from 'react';
import { saasBillingService, BillingStatus, PlanPrice } from '@app/services/saasBillingService';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import type { TierLevel } from '@app/types/billing';
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } from "react";
import { saasBillingService, BillingStatus, PlanPrice } from "@app/services/saasBillingService";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import type { TierLevel } from "@app/types/billing";
// How long plan pricing is considered fresh. Lives at module level so both the
// provider and usePlanPricing (the consumer hook) can reference it.
@@ -26,8 +26,8 @@ interface SaasBillingContextType {
// Billing Status
billingStatus: BillingStatus | null;
tier: TierLevel;
subscription: BillingStatus['subscription'];
usage: BillingStatus['meterUsage'];
subscription: BillingStatus["subscription"];
usage: BillingStatus["meterUsage"];
isTrialing: boolean;
trialDaysRemaining?: number;
price?: number;
@@ -57,7 +57,7 @@ interface SaasBillingContextType {
const SaasBillingContext = createContext<SaasBillingContextType>({
billingStatus: null,
tier: 'free',
tier: "free",
subscription: null,
usage: null,
isTrialing: false,
@@ -84,7 +84,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
const [plans, setPlans] = useState<Map<string, PlanPrice>>(new Map());
const [plansLoading, setPlansLoading] = useState(false);
const [plansError, setPlansError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); // Start false (lazy load)
const [loading, setLoading] = useState(false); // Start false (lazy load)
const [error, setError] = useState<string | null>(null);
// lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure).
// lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context.
@@ -113,7 +113,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
const checkAccess = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === 'saas');
setIsSaasMode(mode === "saas");
setIsAuthenticated(auth);
};
@@ -127,7 +127,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
@@ -151,7 +151,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
// Cache check: Skip if fresh data exists (<5 min old)
const now = Date.now();
if (billingStatusRef.current && lastFetchTimeRef.current && (now - lastFetchTimeRef.current) < 300000) {
if (billingStatusRef.current && lastFetchTimeRef.current && now - lastFetchTimeRef.current < 300000) {
return;
}
@@ -168,8 +168,8 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
setLastFetchTimeValue(now);
setError(null);
} catch (err) {
console.error('[SaasBillingContext] Failed to fetch billing:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch billing');
console.error("[SaasBillingContext] Failed to fetch billing:", err);
setError(err instanceof Error ? err.message : "Failed to fetch billing");
// Don't clear billing status on error (preserve existing data)
} finally {
setLoading(false);
@@ -192,14 +192,14 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
setPlansError(null);
try {
const priceMap = await saasBillingService.getAvailablePlans('usd');
const priceMap = await saasBillingService.getAvailablePlans("usd");
setPlans(priceMap);
const now = Date.now();
plansLastFetchTimeRef.current = now;
setPlansLastFetchTimeValue(now);
} catch (err) {
console.error('[SaasBillingContext] Failed to fetch plans:', err);
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
console.error("[SaasBillingContext] Failed to fetch plans:", err);
setPlansError(err instanceof Error ? err.message : "Failed to fetch plans");
} finally {
plansFetchInProgressRef.current = false;
setPlansLoading(false);
@@ -272,7 +272,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
const customEvent = e as CustomEvent<{ creditsRemaining: number }>;
const newBalance = customEvent.detail?.creditsRemaining;
if (typeof newBalance === 'number' && billingStatus) {
if (typeof newBalance === "number" && billingStatus) {
// Update credit balance in billing status without full refresh
const updated = { ...billingStatus, creditBalance: newBalance };
billingStatusRef.current = updated;
@@ -280,68 +280,76 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
// Dispatch exhausted event if credits hit 0
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
window.dispatchEvent(new CustomEvent('credits:exhausted', {
detail: { previousBalance: billingStatus.creditBalance ?? 0, currentBalance: newBalance }
}));
window.dispatchEvent(
new CustomEvent("credits:exhausted", {
detail: { previousBalance: billingStatus.creditBalance ?? 0, currentBalance: newBalance },
}),
);
}
}
};
window.addEventListener('credits:updated', handleCreditsUpdated);
window.addEventListener("credits:updated", handleCreditsUpdated);
return () => {
window.removeEventListener('credits:updated', handleCreditsUpdated);
window.removeEventListener("credits:updated", handleCreditsUpdated);
};
}, [billingStatus]);
const contextValue = useMemo(() => ({
billingStatus,
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: plans.get('team')?.price,
currency: plans.get('team')?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
plansLastFetchTime: plansLastFetchTimeValue,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime: lastFetchTimeValue,
refreshBilling,
refreshCredits: refreshBilling,
refreshPlans,
openBillingPortal,
}), [
billingStatus, isManagedTeamMember, plans, plansLoading, plansError,
plansLastFetchTimeValue, loading, teamLoading, error, lastFetchTimeValue,
refreshBilling, refreshPlans, openBillingPortal,
]);
return (
<SaasBillingContext.Provider value={contextValue}>
{children}
</SaasBillingContext.Provider>
const contextValue = useMemo(
() => ({
billingStatus,
tier: isManagedTeamMember ? "team" : (billingStatus?.tier ?? "free"),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: plans.get("team")?.price,
currency: plans.get("team")?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
plansLastFetchTime: plansLastFetchTimeValue,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime: lastFetchTimeValue,
refreshBilling,
refreshCredits: refreshBilling,
refreshPlans,
openBillingPortal,
}),
[
billingStatus,
isManagedTeamMember,
plans,
plansLoading,
plansError,
plansLastFetchTimeValue,
loading,
teamLoading,
error,
lastFetchTimeValue,
refreshBilling,
refreshPlans,
openBillingPortal,
],
);
return <SaasBillingContext.Provider value={contextValue}>{children}</SaasBillingContext.Provider>;
}
export function useSaaSBilling() {
const context = useContext(SaasBillingContext);
if (context === undefined) {
throw new Error('useSaaSBilling must be used within SaasBillingProvider');
throw new Error("useSaaSBilling must be used within SaasBillingProvider");
}
// Lazy fetch: Trigger fetch on first access (after team context loads)
// Note: context.loading includes teamLoading, so this waits for team to load
useEffect(() => {
const needsFetch =
context.billingStatus === null &&
context.lastFetchTime === null &&
!context.loading &&
!context.isManagedTeamMember; // Managed members don't need billing data
context.billingStatus === null && context.lastFetchTime === null && !context.loading && !context.isManagedTeamMember; // Managed members don't need billing data
if (needsFetch) {
context.refreshBilling();
@@ -360,9 +368,7 @@ export function usePlanPricing() {
const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } = useContext(SaasBillingContext);
useEffect(() => {
const isFresh =
plansLastFetchTime !== null &&
Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
const isFresh = plansLastFetchTime !== null && Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
if (!isFresh) {
refreshPlans();
@@ -1,4 +1,4 @@
import { connectionModeService } from '@app/services/connectionModeService';
import { connectionModeService } from "@app/services/connectionModeService";
type SignOutFn = () => Promise<void>;
@@ -17,18 +17,18 @@ export function useAccountLogout() {
const currentConfig = await connectionModeService.getCurrentConfig();
// Save server URL before clearing so user can easily reconnect (self-hosted only)
if (currentConfig.mode === 'selfhosted' && currentConfig.server_config?.url) {
localStorage.setItem('server_url', currentConfig.server_config.url);
if (currentConfig.mode === "selfhosted" && currentConfig.server_config?.url) {
localStorage.setItem("server_url", currentConfig.server_config.url);
}
// Always switch to local after logout so the app remains usable
await connectionModeService.switchToLocal();
window.history.replaceState({}, '', '/');
window.history.replaceState({}, "", "/");
// No reload needed — AppProviders remounts the SaaS provider tree via
// connectionModeService subscription when mode changes to local.
return;
} catch (err) {
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
console.warn("[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect", err);
}
redirectToLogin();
@@ -3,12 +3,12 @@
*/
export async function handleAuthCallbackSuccess(token: string): Promise<void> {
// Notify desktop popup listeners (self-hosted SSO flow)
const isDesktopPopup = typeof window !== 'undefined' && window.opener && window.name === 'stirling-desktop-sso';
const isDesktopPopup = typeof window !== "undefined" && window.opener && window.name === "stirling-desktop-sso";
if (isDesktopPopup) {
try {
window.opener.postMessage({ type: 'stirling-desktop-sso', token }, '*');
window.opener.postMessage({ type: "stirling-desktop-sso", token }, "*");
} catch (postError) {
console.error('[AuthCallback] Failed to notify desktop window:', postError);
console.error("[AuthCallback] Failed to notify desktop window:", postError);
}
// Give the message a moment to flush before attempting to close
@@ -1,4 +1,4 @@
import { authService } from '@app/services/authService';
import { authService } from "@app/services/authService";
/**
* Desktop-specific auth cleanup hooks.
@@ -7,7 +7,7 @@ export async function clearPlatformAuthAfterSignOut(): Promise<void> {
try {
await authService.localClearAuth();
} catch (err) {
console.warn('[AuthCleanup] Failed to clear desktop auth data after sign out', err);
console.warn("[AuthCleanup] Failed to clear desktop auth data after sign out", err);
}
}
@@ -15,16 +15,16 @@ export async function clearPlatformAuthOnLoginInit(): Promise<void> {
try {
// Only clear if there's NO token in storage
// If token exists, user just logged in and we should keep it
const token = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
console.log('[AuthCleanup] Login init check - token exists:', !!token, 'length:', token?.length || 0);
const token = typeof window !== "undefined" ? localStorage.getItem("stirling_jwt") : null;
console.log("[AuthCleanup] Login init check - token exists:", !!token, "length:", token?.length || 0);
if (!token) {
console.log('[AuthCleanup] No token found on login init, clearing stale auth data');
console.log("[AuthCleanup] No token found on login init, clearing stale auth data");
await authService.localClearAuth();
} else {
console.log('[AuthCleanup] Token present on login init (length:', token.length, '), skipping cleanup (fresh login)');
console.log("[AuthCleanup] Token present on login init (length:", token.length, "), skipping cleanup (fresh login)");
}
} catch (err) {
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
console.warn("[AuthCleanup] Failed to clear desktop auth data on login init", err);
}
}
@@ -2,6 +2,6 @@ export function getCookieConsentOverrides(): Record<string, unknown> {
return {
cookie: {
useLocalStorage: true, // Cookies don't reliably persist on desktop, but localStorage does
}
},
};
}
@@ -1,5 +1,5 @@
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
/**
* Desktop-specific OAuth navigation: prefer popup/system browser, avoid hijacking main webview.
@@ -17,7 +17,7 @@ export async function startOAuthNavigation(redirectUrl: string): Promise<boolean
await authService.loginWithSelfHostedOAuth(providerPath, serverUrl);
return true;
} catch (error) {
console.warn('[Desktop OAuthNavigation] Failed to start OAuth flow', error);
console.warn("[Desktop OAuthNavigation] Failed to start OAuth flow", error);
return false;
}
}
@@ -1,7 +1,7 @@
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import type { PlatformSessionUser } from '@proprietary/extensions/platformSessionBridge';
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
import type { PlatformSessionUser } from "@proprietary/extensions/platformSessionBridge";
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
try {
@@ -9,7 +9,7 @@ export async function isDesktopSaaSAuthMode(): Promise<boolean> {
// Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
// This skips redundant backend validation in springAuthClient since desktop authService
// already manages the token lifecycle
return mode === 'saas' || mode === 'selfhosted';
return mode === "saas" || mode === "selfhosted";
} catch {
return false;
}
@@ -33,9 +33,9 @@ export async function getPlatformSessionUser(): Promise<PlatformSessionUser | nu
export async function refreshPlatformSession(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
if (mode === "saas") {
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
} else if (mode === 'selfhosted') {
} else if (mode === "selfhosted") {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
return false;
@@ -56,7 +56,7 @@ export async function savePlatformToken(token: string): Promise<void> {
try {
await authService.saveToken(token);
} catch (error) {
console.error('[PlatformBridge] Failed to save token:', error);
console.error("[PlatformBridge] Failed to save token:", error);
throw error;
}
}
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { fileOpenService } from '@app/services/fileOpenService';
import { useFileManagement } from '@app/contexts/file/fileHooks';
import { createQuickKey } from '@app/types/fileContext';
import { useEffect, useState } from "react";
import { useOpenedFile } from "@app/hooks/useOpenedFile";
import { fileOpenService } from "@app/services/fileOpenService";
import { useFileManagement } from "@app/contexts/file/fileHooks";
import { createQuickKey } from "@app/types/fileContext";
/**
* App initialization hook
@@ -37,10 +37,10 @@ export function useAppInitialization(): void {
if (!fileData) return null;
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
type: "application/pdf",
});
console.log('[Desktop] Loaded file:', fileData.fileName);
console.log("[Desktop] Loaded file:", fileData.fileName);
return {
file,
@@ -48,19 +48,19 @@ export function useAppInitialization(): void {
quickKey: createQuickKey(file),
};
} catch (error) {
console.error('[Desktop] Failed to load file:', filePath, error);
console.error("[Desktop] Failed to load file:", filePath, error);
return null;
}
})
}),
)
).filter((entry): entry is { file: File; filePath: string; quickKey: string } => Boolean(entry));
if (loadedFiles.length > 0) {
const filesArray = loadedFiles.map(entry => entry.file);
const quickKeyToPath = new Map(loadedFiles.map(entry => [entry.quickKey, entry.filePath]));
const filesArray = loadedFiles.map((entry) => entry.file);
const quickKeyToPath = new Map(loadedFiles.map((entry) => [entry.quickKey, entry.filePath]));
const addedFiles = await addFiles(filesArray, { selectFiles: true });
addedFiles.forEach(file => {
addedFiles.forEach((file) => {
const localFilePath = quickKeyToPath.get(file.quickKey);
if (localFilePath) {
updateStirlingFileStub(file.fileId, { localFilePath });
@@ -70,7 +70,7 @@ export function useAppInitialization(): void {
console.log(`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`);
}
} catch (error) {
console.error('[Desktop] Failed to load opened files:', error);
console.error("[Desktop] Failed to load opened files:", error);
}
};
+11 -18
View File
@@ -1,9 +1,9 @@
import { useEffect, useState, useCallback } from 'react';
import { backendHealthMonitor } from '@app/services/backendHealthMonitor';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { connectionModeService } from '@app/services/connectionModeService';
import type { BackendHealthState } from '@app/types/backendHealth';
import { useEffect, useState, useCallback } from "react";
import { backendHealthMonitor } from "@app/services/backendHealthMonitor";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { connectionModeService } from "@app/services/connectionModeService";
import type { BackendHealthState } from "@app/types/backendHealth";
/**
* Hook to read backend health state for UI (Run button, BackendHealthIndicator).
@@ -20,17 +20,13 @@ import type { BackendHealthState } from '@app/types/backendHealth';
*/
export function useBackendHealth() {
const [health, setHealth] = useState<BackendHealthState>(() => backendHealthMonitor.getSnapshot());
const [serverStatus, setServerStatus] = useState(
() => selfHostedServerMonitor.getSnapshot().status
);
const [localUrl, setLocalUrl] = useState<string | null>(
() => tauriBackendService.getBackendUrl()
);
const [serverStatus, setServerStatus] = useState(() => selfHostedServerMonitor.getSnapshot().status);
const [localUrl, setLocalUrl] = useState<string | null>(() => tauriBackendService.getBackendUrl());
const [connectionMode, setConnectionMode] = useState<string | null>(null);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
return connectionModeService.subscribeToModeChanges((config) => setConnectionMode(config.mode));
}, []);
useEffect(() => {
@@ -38,7 +34,7 @@ export function useBackendHealth() {
}, []);
useEffect(() => {
return selfHostedServerMonitor.subscribe(state => setServerStatus(state.status));
return selfHostedServerMonitor.subscribe((state) => setServerStatus(state.status));
}, []);
useEffect(() => {
@@ -51,10 +47,7 @@ export function useBackendHealth() {
return backendHealthMonitor.checkNow();
}, []);
const isOnline =
connectionMode === 'selfhosted'
? serverStatus !== 'offline' || !!localUrl
: health.isOnline;
const isOnline = connectionMode === "selfhosted" ? serverStatus !== "offline" || !!localUrl : health.isOnline;
return {
...health,
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { useEndpointConfig } from '@app/hooks/useEndpointConfig';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { useEffect } from "react";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
import { useEndpointConfig } from "@app/hooks/useEndpointConfig";
import { tauriBackendService } from "@app/services/tauriBackendService";
/**
* Hook to initialize backend and monitor health
@@ -32,12 +32,12 @@ export function useBackendInitializer(enabled = true) {
void checkHealth();
}, 500);
} catch (error) {
console.error('[BackendInitializer] Failed to start backend:', error);
console.error("[BackendInitializer] Failed to start backend:", error);
}
};
// Only start backend if it's not already starting/healthy
if (status !== 'healthy' && status !== 'starting') {
if (status !== "healthy" && status !== "starting") {
void initializeBackend();
}
}, [enabled, status, backendUrl, checkHealth]);
@@ -1,18 +1,18 @@
import { useState, useEffect } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
import { getEndpointName } from '@app/utils/convertUtils';
import { useState, useEffect } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { EXTENSION_TO_ENDPOINT } from "@app/constants/convertConstants";
import { getEndpointName } from "@app/utils/convertUtils";
/**
* Comprehensive conversion status data
*/
export interface ConversionStatus {
availability: Record<string, boolean>; // Available on local OR SaaS?
cloudStatus: Record<string, boolean>; // Will use cloud?
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
availability: Record<string, boolean>; // Available on local OR SaaS?
cloudStatus: Record<string, boolean>; // Will use cloud?
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
}
/**
@@ -33,10 +33,10 @@ export function useConversionCloudStatus(): ConversionStatus {
// Self-hosted offline path: server is down but local backend is available.
// Check each conversion against the local backend only (no cloud routing).
if (mode === 'selfhosted') {
if (mode === "selfhosted") {
const { status } = selfHostedServerMonitor.getSnapshot();
const localUrl = tauriBackendService.getBackendUrl();
if (status === 'offline' && localUrl) {
if (status === "offline" && localUrl) {
const pairs: [string, string, string][] = [];
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
for (const toExt of Object.keys(EXTENSION_TO_ENDPOINT[fromExt] || {})) {
@@ -56,7 +56,7 @@ export function useConversionCloudStatus(): ConversionStatus {
} catch {
return { key, supported: false };
}
})
}),
);
for (const { key, supported } of results) {
availability[key] = supported;
@@ -77,7 +77,7 @@ export function useConversionCloudStatus(): ConversionStatus {
return;
}
if (mode !== 'saas') {
if (mode !== "saas") {
// Non-SaaS, non-self-hosted: local endpoint checking handles everything
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
@@ -105,7 +105,7 @@ export function useConversionCloudStatus(): ConversionStatus {
// used by useMultipleEndpointsEnabled's SaaS enhancement.
const availableLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl()
tauriBackendService.getBackendUrl(),
);
return { key, isAvailable: true, willUseCloud: !availableLocally, localOnly: false };
} catch (error) {
@@ -113,7 +113,7 @@ export function useConversionCloudStatus(): ConversionStatus {
// On error, assume available via cloud (safe default in SaaS mode)
return { key, isAvailable: true, willUseCloud: true, localOnly: false };
}
})
}),
);
for (const { key, isAvailable, willUseCloud: wuc, localOnly: lo } of results) {
@@ -130,7 +130,7 @@ export function useConversionCloudStatus(): ConversionStatus {
// Re-check when SaaS local backend becomes healthy
const unsubLocal = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
if (status === "healthy") {
checkConversions();
}
});
@@ -143,7 +143,10 @@ export function useConversionCloudStatus(): ConversionStatus {
// Skip the first invocation since checkConversions() was already called above.
let skipFirst = true;
const unsubServer = selfHostedServerMonitor.subscribe(() => {
if (skipFirst) { skipFirst = false; return; }
if (skipFirst) {
skipFirst = false;
return;
}
void checkConversions();
});
+43 -39
View File
@@ -1,11 +1,11 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSMode } from '@app/hooks/useSaaSMode';
import { getToolCreditCost } from '@app/utils/creditCosts';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
import { operationRouter } from '@app/services/operationRouter';
import type { ToolId } from '@app/types/toolId';
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { getToolCreditCost } from "@app/utils/creditCosts";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
import { operationRouter } from "@app/services/operationRouter";
import type { ToolId } from "@app/types/toolId";
/**
* Desktop implementation of credit checking for cloud operations.
@@ -21,43 +21,47 @@ export function useCreditCheck(operationType?: string, endpoint?: string) {
const isSaaSMode = useSaaSMode();
const { t } = useTranslation();
const checkCredits = useCallback(async (runtimeEndpoint?: string): Promise<string | null> => {
if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted
if (!billing) return null;
const checkCredits = useCallback(
async (runtimeEndpoint?: string): Promise<string | null> => {
if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted
if (!billing) return null;
// If the operation routes to the local backend, no credits are consumed — skip check.
// runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint.
const ep = runtimeEndpoint ?? endpoint;
if (ep) {
try {
const willUseSaaS = await operationRouter.willRouteToSaaS(ep);
if (!willUseSaaS) return null;
} catch {
// If routing check fails, fall through to credit check as a safe default.
// If the operation routes to the local backend, no credits are consumed — skip check.
// runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint.
const ep = runtimeEndpoint ?? endpoint;
if (ep) {
try {
const willUseSaaS = await operationRouter.willRouteToSaaS(ep);
if (!willUseSaaS) return null;
} catch {
// If routing check fails, fall through to credit check as a safe default.
}
}
}
const { creditBalance, loading } = billing;
const requiredCredits = getToolCreditCost(operationType as ToolId);
const { creditBalance, loading } = billing;
const requiredCredits = getToolCreditCost(operationType as ToolId);
if (!loading && creditBalance < requiredCredits) {
window.dispatchEvent(new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, {
detail: {
operationType,
requiredCredits,
currentBalance: creditBalance,
},
}));
if (!loading && creditBalance < requiredCredits) {
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, {
detail: {
operationType,
requiredCredits,
currentBalance: creditBalance,
},
}),
);
return t(
'credits.insufficient.brief',
'Insufficient credits. You need {{required}} credits but have {{current}}.',
{ required: requiredCredits, current: creditBalance },
);
}
return t("credits.insufficient.brief", "Insufficient credits. You need {{required}} credits but have {{current}}.", {
required: requiredCredits,
current: creditBalance,
});
}
return null;
}, [billing, isSaaSMode, operationType, endpoint, t]);
return null;
},
[billing, isSaaSMode, operationType, endpoint, t],
);
return { checkCredits };
}
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSMode } from '@app/hooks/useSaaSMode';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
import { useEffect, useRef } from "react";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
/**
* Desktop hook that monitors credit balance and dispatches events
@@ -22,7 +22,7 @@ export function useCreditEvents() {
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: { previousBalance: prevBalance, currentBalance: creditBalance },
})
}),
);
}
+17 -17
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { defaultAppService } from '@app/services/defaultAppService';
import { alert } from '@app/components/toast';
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { defaultAppService } from "@app/services/defaultAppService";
import { alert } from "@app/components/toast";
export const useDefaultApp = () => {
const { t } = useTranslation();
@@ -17,7 +17,7 @@ export const useDefaultApp = () => {
const status = await defaultAppService.isDefaultPdfHandler();
setIsDefault(status);
} catch (error) {
console.error('Failed to check default status:', error);
console.error("Failed to check default status:", error);
}
};
@@ -26,26 +26,26 @@ export const useDefaultApp = () => {
try {
const result = await defaultAppService.setAsDefaultPdfHandler();
if (result === 'set_successfully') {
if (result === "set_successfully") {
alert({
alertType: 'success',
title: t('defaultApp.success.title', 'Default App Set'),
body: t('defaultApp.success.message', 'Stirling PDF is now your default PDF editor'),
alertType: "success",
title: t("defaultApp.success.title", "Default App Set"),
body: t("defaultApp.success.message", "Stirling PDF is now your default PDF editor"),
});
setIsDefault(true);
} else if (result === 'opened_dialog') {
} else if (result === "opened_dialog") {
alert({
alertType: 'neutral',
title: t('defaultApp.settingsOpened.title', 'Settings Opened'),
body: t('defaultApp.settingsOpened.message', 'Please select Stirling PDF in the file association dialogue'),
alertType: "neutral",
title: t("defaultApp.settingsOpened.title", "Settings Opened"),
body: t("defaultApp.settingsOpened.message", "Please select Stirling PDF in the file association dialogue"),
});
}
} catch (error) {
console.error('Failed to set default:', error);
console.error("Failed to set default:", error);
alert({
alertType: 'error',
title: t('defaultApp.error.title', 'Error'),
body: t('defaultApp.error.message', 'Failed to set default PDF handler'),
alertType: "error",
title: t("defaultApp.error.title", "Error"),
body: t("defaultApp.error.message", "Failed to set default PDF handler"),
});
} finally {
setIsLoading(false);
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { supabase } from '@app/auth/supabase';
import { authService } from '@app/services/authService';
import { useState } from "react";
import { supabase } from "@app/auth/supabase";
import { authService } from "@app/services/authService";
/**
* Shared hook for enabling metered (overage) billing via Supabase edge function.
@@ -13,7 +13,7 @@ import { authService } from '@app/services/authService';
export function useEnableMeteredBilling(
refreshBilling: () => Promise<void>,
onSuccess: () => void,
logPrefix: string
logPrefix: string,
): {
enablingMetering: boolean;
meteringError: string | null;
@@ -30,20 +30,20 @@ export function useEnableMeteredBilling(
try {
const token = await authService.getAuthToken();
if (!token) {
throw new Error('Not authenticated');
throw new Error("Not authenticated");
}
const { data, error } = await supabase.functions.invoke('create-meter-subscription', {
method: 'POST',
const { data, error } = await supabase.functions.invoke("create-meter-subscription", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (error) {
throw new Error(error.message || 'Failed to enable metered billing');
throw new Error(error.message || "Failed to enable metered billing");
}
if (!data?.success) {
throw new Error(data?.error || data?.message || 'Failed to enable metered billing');
throw new Error(data?.error || data?.message || "Failed to enable metered billing");
}
console.debug(`[${logPrefix}] Metered billing enabled successfully`);
@@ -51,7 +51,7 @@ export function useEnableMeteredBilling(
await refreshBilling();
onSuccess();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to enable metered billing';
const message = err instanceof Error ? err.message : "Failed to enable metered billing";
console.error(`[${logPrefix}] Failed to enable metered billing:`, err);
setMeteringError(message);
} finally {
+80 -73
View File
@@ -1,15 +1,14 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
import { connectionModeService } from '@app/services/connectionModeService';
import type { AppConfig } from '@app/contexts/AppConfigContext';
import { useState, useEffect, useCallback, useRef } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { isBackendNotReadyError } from "@app/constants/backendErrors";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
import { connectionModeService } from "@app/services/connectionModeService";
import type { AppConfig } from "@app/contexts/AppConfigContext";
interface EndpointConfig {
backendUrl: string;
@@ -18,34 +17,31 @@ interface EndpointConfig {
const RETRY_DELAY_MS = 2500;
function isSelfHostedOffline(): boolean {
return (
selfHostedServerMonitor.getSnapshot().status === 'offline' &&
!!tauriBackendService.getBackendUrl()
);
return selfHostedServerMonitor.getSnapshot().status === "offline" && !!tauriBackendService.getBackendUrl();
}
function getErrorMessage(err: unknown): string {
if (isAxiosError(err)) {
const data = err.response?.data as { message?: string } | undefined;
if (typeof data?.message === 'string') {
if (typeof data?.message === "string") {
return data.message;
}
return err.message || 'Unknown error occurred';
return err.message || "Unknown error occurred";
}
if (err instanceof Error) {
return err.message;
}
return 'Unknown error occurred';
return "Unknown error occurred";
}
async function checkDependenciesReady(): Promise<boolean> {
try {
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', {
const response = await apiClient.get<AppConfig>("/api/v1/config/app-config", {
suppressErrorToast: true,
});
return response.data?.dependenciesReady ?? false;
} catch (error) {
console.debug('[useEndpointConfig] Dependencies not ready yet:', error);
console.debug("[useEndpointConfig] Dependencies not ready yet:", error);
return false;
}
}
@@ -100,9 +96,12 @@ export function useEndpointEnabled(endpoint: string): {
try {
setError(null);
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, {
suppressErrorToast: true,
});
const response = await apiClient.get<boolean>(
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
{
suppressErrorToast: true,
},
);
const locallyEnabled = response.data;
@@ -110,7 +109,7 @@ export function useEndpointEnabled(endpoint: string): {
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`);
setEnabled(true);
return;
@@ -123,7 +122,7 @@ export function useEndpointEnabled(endpoint: string): {
const message = getErrorMessage(err);
if (isBackendStarting) {
setError(t('backendHealth.starting', 'Backend starting up...'));
setError(t("backendHealth.starting", "Backend starting up..."));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
@@ -133,7 +132,7 @@ export function useEndpointEnabled(endpoint: string): {
} else {
// DESKTOP ENHANCEMENT: In SaaS mode, assume available even on check failure
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
if (mode === "saas") {
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
setEnabled(true); // Available via SaaS
setError(null);
@@ -174,7 +173,7 @@ export function useEndpointEnabled(endpoint: string): {
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
if (status === "healthy") {
fetchEndpointStatus();
}
});
@@ -234,7 +233,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// checkDependenciesReady() would fail here since it hits the offline remote server.
const { status: serverStatus } = selfHostedServerMonitor.getSnapshot();
const localUrl = tauriBackendService.getBackendUrl();
if (serverStatus === 'offline' && localUrl) {
if (serverStatus === "offline" && localUrl) {
const results = await Promise.all(
[...new Set(endpoints)].map(async (ep) => {
try {
@@ -243,17 +242,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
} catch {
return { ep, supported: false };
}
})
}),
);
if (!isMountedRef.current) return;
const statusMap: Record<string, boolean> = {};
const details: Record<string, EndpointAvailabilityDetails> = {};
for (const { ep, supported } of results) {
statusMap[ep] = supported;
details[ep] = { enabled: supported, reason: supported ? null : 'NOT_SUPPORTED_LOCALLY' };
details[ep] = { enabled: supported, reason: supported ? null : "NOT_SUPPORTED_LOCALLY" };
}
setEndpointDetails(prev => ({ ...prev, ...details }));
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
setEndpointDetails((prev) => ({ ...prev, ...details }));
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
setLoading(false);
return;
}
@@ -271,60 +270,66 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// "large query reduction" change and still require the parameter.
let response: Awaited<ReturnType<typeof apiClient.get<Record<string, EndpointAvailabilityDetails>>>>;
try {
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
`/api/v1/config/endpoints-availability`,
{ suppressErrorToast: true }
);
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability`, {
suppressErrorToast: true,
});
} catch (innerErr) {
if (isAxiosError(innerErr) && innerErr.response?.status === 400) {
// Old server — requires explicit endpoints query param
console.debug('[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format');
const endpointsParam = endpoints.join(',');
console.debug("[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format");
const endpointsParam = endpoints.join(",");
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
{ suppressErrorToast: true }
{ suppressErrorToast: true },
);
} else {
throw innerErr;
}
}
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
acc[endpointName] = {
enabled: detail?.enabled ?? false,
reason: detail?.reason ?? null,
};
return acc;
}, {} as Record<string, EndpointAvailabilityDetails>);
const details = Object.entries(response.data).reduce(
(acc, [endpointName, detail]) => {
acc[endpointName] = {
enabled: detail?.enabled ?? false,
reason: detail?.reason ?? null,
};
return acc;
},
{} as Record<string, EndpointAvailabilityDetails>,
);
const statusMap = Object.keys(details).reduce((acc, key) => {
acc[key] = details[key].enabled;
return acc;
}, {} as Record<string, boolean>);
const statusMap = Object.keys(details).reduce(
(acc, key) => {
acc[key] = details[key].enabled;
return acc;
},
{} as Record<string, boolean>,
);
const mode = await connectionModeService.getCurrentMode();
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
// They will route to SaaS backend
if (mode === 'saas') {
const disabledEndpoints = Object.keys(details).filter(key => !details[key].enabled);
if (mode === "saas") {
const disabledEndpoints = Object.keys(details).filter((key) => !details[key].enabled);
for (const endpoint of disabledEndpoints) {
console.debug(`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
console.debug(
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
);
statusMap[endpoint] = true; // Mark as enabled via SaaS
details[endpoint] = { enabled: true, reason: null };
}
}
setEndpointDetails(prev => ({ ...prev, ...details }));
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
setEndpointDetails((prev) => ({ ...prev, ...details }));
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
if (isBackendStarting) {
setError(t('backendHealth.starting', 'Backend starting up...'));
setError(t("backendHealth.starting", "Backend starting up..."));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
@@ -333,16 +338,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
} else {
setError(message);
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
const fallbackDetail: EndpointAvailabilityDetails = { enabled: false, reason: 'UNKNOWN' };
acc.status[endpointName] = false;
acc.details[endpointName] = fallbackDetail;
return acc;
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
const fallbackStatus = endpoints.reduce(
(acc, endpointName) => {
const fallbackDetail: EndpointAvailabilityDetails = { enabled: false, reason: "UNKNOWN" };
acc.status[endpointName] = false;
acc.details[endpointName] = fallbackDetail;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> },
);
// DESKTOP ENHANCEMENT: In SaaS mode, mark all endpoints as available
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
if (mode === "saas") {
for (const endpoint of endpoints) {
console.debug(`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
fallbackStatus.status[endpoint] = true;
@@ -351,7 +359,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
setEndpointStatus(fallbackStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
setEndpointDetails((prev) => ({ ...prev, ...fallbackStatus.details }));
}
} finally {
setLoading(false);
@@ -381,7 +389,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
if (status === "healthy") {
fetchAllEndpointStatuses();
}
});
@@ -401,9 +409,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
// Default backend URL from environment variables
const DEFAULT_BACKEND_URL =
import.meta.env.VITE_DESKTOP_BACKEND_URL
|| import.meta.env.VITE_API_BASE_URL;
const DEFAULT_BACKEND_URL = import.meta.env.VITE_DESKTOP_BACKEND_URL || import.meta.env.VITE_API_BASE_URL;
/**
* Desktop override exposing the backend URL based on connection mode.
@@ -414,9 +420,10 @@ export function useEndpointConfig(): EndpointConfig {
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
useEffect(() => {
connectionModeService.getCurrentConfig()
connectionModeService
.getCurrentConfig()
.then((config) => {
if (config.mode === 'selfhosted' && config.server_config?.url) {
if (config.mode === "selfhosted" && config.server_config?.url) {
setBackendUrl(config.server_config.url);
} else {
// SaaS mode - use default from env vars (local backend)
@@ -424,7 +431,7 @@ export function useEndpointConfig(): EndpointConfig {
}
})
.catch((err) => {
console.error('Failed to get connection config:', err);
console.error("Failed to get connection config:", err);
// Keep current URL on error
});
}, []);
+29 -30
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { message } from '@tauri-apps/plugin-dialog';
import { useFileState, useFileActions } from '@app/contexts/FileContext';
import { downloadFile } from '@app/services/downloadService';
import type { StirlingFileStub } from '@app/types/fileContext';
import { useTranslation } from 'react-i18next';
import { useEffect, useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { downloadFile } from "@app/services/downloadService";
import type { StirlingFileStub } from "@app/types/fileContext";
import { useTranslation } from "react-i18next";
export function useExitWarning() {
const { t } = useTranslation();
@@ -26,29 +26,29 @@ export function useExitWarning() {
}
const allStubs = selectorsRef.current.getStirlingFileStubs();
const dirtyStubs = allStubs.filter(stub => stub.isDirty);
const dirtyStubs = allStubs.filter((stub) => stub.isDirty);
if (dirtyStubs.length > 0) {
const fileList = dirtyStubs.map(f => `${f.name}`).join('\n');
const saveLabel = t('confirmCloseSave', 'Save and close');
const discardLabel = t('confirmCloseDiscard', 'Discard changes and close');
const cancelLabel = t('confirmCloseCancel', 'Cancel');
const fileList = dirtyStubs.map((f) => `${f.name}`).join("\n");
const saveLabel = t("confirmCloseSave", "Save and close");
const discardLabel = t("confirmCloseDiscard", "Discard changes and close");
const cancelLabel = t("confirmCloseCancel", "Cancel");
const choice = await message(
t(
'confirmCloseUnsavedList',
'You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}',
{ count: dirtyStubs.length, plural: dirtyStubs.length > 1 ? 's' : '', fileList }
),
t("confirmCloseUnsavedList", "You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}", {
count: dirtyStubs.length,
plural: dirtyStubs.length > 1 ? "s" : "",
fileList,
}),
{
title: t('confirmCloseUnsaved', 'This file has unsaved changes.'),
kind: 'warning',
title: t("confirmCloseUnsaved", "This file has unsaved changes."),
kind: "warning",
buttons: {
yes: saveLabel,
no: discardLabel,
cancel: cancelLabel,
},
}
},
);
if (choice === cancelLabel) {
@@ -62,12 +62,11 @@ export function useExitWarning() {
}
if (failedCount > 0) {
await message(
t(
'confirmCloseSaveFailed',
'Saved with errors. {{count}} file{{plural}} could not be saved.',
{ count: failedCount, plural: failedCount > 1 ? 's' : '' }
),
{ title: t('confirmCloseSaveFailedTitle', 'Save Failed'), kind: 'error' }
t("confirmCloseSaveFailed", "Saved with errors. {{count}} file{{plural}} could not be saved.", {
count: failedCount,
plural: failedCount > 1 ? "s" : "",
}),
{ title: t("confirmCloseSaveFailedTitle", "Save Failed"), kind: "error" },
);
return;
}
@@ -80,21 +79,21 @@ export function useExitWarning() {
try {
await appWindow.destroy();
} catch (error) {
console.error('[exit-warning] destroy failed', error);
console.error("[exit-warning] destroy failed", error);
isClosingRef.current = false;
}
};
const unlisten = appWindow.onCloseRequested(handleCloseRequested);
return () => {
unlisten.then(fn => {
unlisten.then((fn) => {
fn();
});
};
}, [fileActions, t]);
const saveDirtyFiles = async (dirtyStubs: StirlingFileStub[]) => {
const filesById = new Map(selectorsRef.current.getFiles().map(file => [file.fileId, file]));
const filesById = new Map(selectorsRef.current.getFiles().map((file) => [file.fileId, file]));
let failedCount = 0;
let cancelled = false;
@@ -122,7 +121,7 @@ export function useExitWarning() {
if (result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false
isDirty: false,
});
}
} catch {
@@ -1,5 +1,5 @@
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import SaveOutlinedIcon from "@mui/icons-material/SaveOutlined";
/**
* File action icons for desktop builds.
@@ -10,10 +10,10 @@ export function useFileActionIcons() {
return {
upload: FolderOpenOutlinedIcon,
download: SaveOutlinedIcon,
uploadIconName: 'folder-rounded' as const,
downloadIconName: 'save-rounded' as const,
uploadIconName: "folder-rounded" as const,
downloadIconName: "save-rounded" as const,
// Returning this icon name causes RightRail to render the Save As button.
// On desktop, downloadFile() without a localPath shows a native save dialog.
saveAsIconName: 'save-as-rounded' as string | undefined,
saveAsIconName: "save-as-rounded" as string | undefined,
};
}
@@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
/**
* File action terminology for desktop builds
@@ -8,17 +8,17 @@ export function useFileActionTerminology() {
const { t } = useTranslation();
return {
uploadFiles: t('fileUpload.openFiles', 'Open Files'),
uploadFile: t('fileUpload.openFile', 'Open File'),
upload: t('fileUpload.open', 'Open'),
dropFilesHere: t('fileUpload.dropFilesHereOpen', 'Drop files here or click the open button'),
addFiles: t('fileUpload.openFiles', 'Open Files'),
mobileUpload: t('landing.mobileUpload', 'Upload from Mobile'),
uploadFromComputer: t('landing.openFromComputer', 'Open from computer'),
download: t('save', 'Save'),
downloadAll: t('rightRail.saveAll', 'Save All'),
downloadSelected: t('fileManager.saveSelected', 'Save Selected'),
downloadUnavailable: t('saveUnavailable', 'Save unavailable for this item'),
noFilesInStorage: t('fileUpload.noFilesInStorageOpen', 'No files available in storage. Open some files first.'),
uploadFiles: t("fileUpload.openFiles", "Open Files"),
uploadFile: t("fileUpload.openFile", "Open File"),
upload: t("fileUpload.open", "Open"),
dropFilesHere: t("fileUpload.dropFilesHereOpen", "Drop files here or click the open button"),
addFiles: t("fileUpload.openFiles", "Open Files"),
mobileUpload: t("landing.mobileUpload", "Upload from Mobile"),
uploadFromComputer: t("landing.openFromComputer", "Open from computer"),
download: t("save", "Save"),
downloadAll: t("rightRail.saveAll", "Save All"),
downloadSelected: t("fileManager.saveSelected", "Save Selected"),
downloadUnavailable: t("saveUnavailable", "Save unavailable for this item"),
noFilesInStorage: t("fileUpload.noFilesInStorageOpen", "No files available in storage. Open some files first."),
};
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { useEffect, useRef, useState } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
/**
* First launch check hook
@@ -27,7 +27,7 @@ export function useFirstLaunchCheck(): { isFirstLaunch: boolean; setupComplete:
setupCheckCompleteRef.current = true;
} catch (error) {
console.error('Failed to check first launch:', error);
console.error("Failed to check first launch:", error);
// On error, assume not first launch and proceed
setIsFirstLaunch(false);
setSetupComplete(true);
+10 -10
View File
@@ -1,10 +1,10 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import type { GroupEnabledResult } from '@app/types/groupEnabled';
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import type { GroupEnabledResult } from "@app/types/groupEnabled";
const OFFLINE_REASON_FALLBACK = 'Requires your Stirling-PDF server (currently offline)';
const OFFLINE_REASON_FALLBACK = "Requires your Stirling-PDF server (currently offline)";
/**
* Desktop override: skips the network request entirely when the self-hosted
@@ -18,7 +18,7 @@ export function useGroupEnabled(group: string): GroupEnabledResult {
// the very first render when offline (t() is not available in useState initialiser).
const [result, setResult] = useState<GroupEnabledResult>(() => {
const { status } = selfHostedServerMonitor.getSnapshot();
if (status === 'offline') {
if (status === "offline") {
return { enabled: false, unavailableReason: OFFLINE_REASON_FALLBACK };
}
return { enabled: null, unavailableReason: null };
@@ -26,17 +26,17 @@ export function useGroupEnabled(group: string): GroupEnabledResult {
useEffect(() => {
const { status } = selfHostedServerMonitor.getSnapshot();
if (status === 'offline') {
if (status === "offline") {
setResult({
enabled: false,
unavailableReason: t('toolPanel.fullscreen.selfHostedOffline', OFFLINE_REASON_FALLBACK),
unavailableReason: t("toolPanel.fullscreen.selfHostedOffline", OFFLINE_REASON_FALLBACK),
});
return;
}
apiClient
.get<boolean>(`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`)
.then(res => setResult({ enabled: res.data, unavailableReason: null }))
.then((res) => setResult({ enabled: res.data, unavailableReason: null }))
.catch(() => setResult({ enabled: false, unavailableReason: null }));
}, [group, t]);
@@ -1,5 +1,5 @@
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useSelfHostedAuth } from '@app/hooks/useSelfHostedAuth';
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
/**
* Desktop override: shared (group) signing requires self-hosted mode AND
+9 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { fileOpenService } from '@app/services/fileOpenService';
import { listen } from '@tauri-apps/api/event';
import { useState, useEffect, useRef, useCallback } from "react";
import { fileOpenService } from "@app/services/fileOpenService";
import { listen } from "@tauri-apps/api/event";
export function useOpenedFile() {
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
@@ -22,10 +22,10 @@ export function useOpenedFile() {
useEffect(() => {
// Function to read and process files from storage
const readFilesFromStorage = async () => {
console.log('🔍 Reading files from storage...');
console.log("🔍 Reading files from storage...");
try {
const filePaths = await fileOpenService.getOpenedFiles();
console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths);
console.log("🔍 fileOpenService.getOpenedFiles() returned:", filePaths);
if (filePaths.length > 0) {
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
@@ -33,7 +33,7 @@ export function useOpenedFile() {
setOpenedFilePaths(filePaths);
}
} catch (error) {
console.error('❌ Failed to read files from storage:', error);
console.error("❌ Failed to read files from storage:", error);
} finally {
setLoading(false);
}
@@ -44,10 +44,10 @@ export function useOpenedFile() {
// Listen for files-changed events (when new files are added to storage)
let unlisten: (() => void) | undefined;
listen('files-changed', async () => {
console.log('📂 files-changed event received, re-reading storage...');
listen("files-changed", async () => {
console.log("📂 files-changed event received, re-reading storage...");
await readFilesFromStorage();
}).then(unlistenFn => {
}).then((unlistenFn) => {
unlisten = unlistenFn;
});
+4 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { useState, useEffect } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
/**
* Returns whether the app is currently in SaaS connection mode.
@@ -10,8 +10,8 @@ export function useSaaSMode(): boolean {
const [isSaaSMode, setIsSaaSMode] = useState(true);
useEffect(() => {
void connectionModeService.getCurrentMode().then(mode => setIsSaaSMode(mode === 'saas'));
return connectionModeService.subscribeToModeChanges(cfg => setIsSaaSMode(cfg.mode === 'saas'));
void connectionModeService.getCurrentMode().then((mode) => setIsSaaSMode(mode === "saas"));
return connectionModeService.subscribeToModeChanges((cfg) => setIsSaaSMode(cfg.mode === "saas"));
}, []);
return isSaaSMode;
+43 -43
View File
@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling, usePlanPricing } from '@app/contexts/SaasBillingContext';
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures';
import type { TierLevel } from '@app/types/billing';
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSaaSBilling, usePlanPricing } from "@app/contexts/SaasBillingContext";
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from "@app/config/planFeatures";
import type { TierLevel } from "@app/types/billing";
export interface PlanFeature {
name: string;
@@ -28,60 +28,60 @@ export const useSaaSPlans = () => {
const { plans, plansLoading, plansError } = usePlanPricing();
const computedPlans = useMemo<PlanTier[]>(() => {
const teamPlan = plans.get('team');
const teamPlan = plans.get("team");
return [
{
id: 'free',
name: t('plan.free.name', 'Free'),
id: "free",
name: t("plan.free.name", "Free"),
price: 0,
currency: '$',
period: t('plan.period.month', '/month'),
highlights: FREE_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
currency: "$",
period: t("plan.period.month", "/month"),
highlights: FREE_PLAN_FEATURES.map((f) => t(f.translationKey, f.defaultText)),
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: false },
{ name: t('plan.feature.api', 'API Access'), included: false },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
{ name: t("plan.feature.fileSize", "File Size Limit"), included: false },
{ name: t("plan.feature.automation", "Automate tool workflows"), included: false },
{ name: t("plan.feature.api", "API Access"), included: false },
{ name: t("plan.feature.priority", "Priority Support"), included: false },
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: false },
],
},
{
id: 'team',
name: t('plan.team.name', 'Team'),
id: "team",
name: t("plan.team.name", "Team"),
price: teamPlan?.price || 10,
currency: teamPlan?.currency || '$',
period: t('plan.period.month', '/month'),
currency: teamPlan?.currency || "$",
period: t("plan.period.month", "/month"),
popular: true,
overagePrice: teamPlan?.overagePrice || 0.05,
highlights: TEAM_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
highlights: TEAM_PLAN_FEATURES.map((f) => t(f.translationKey, f.defaultText)),
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Monthly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
{ name: t("plan.feature.fileSize", "File Size Limit"), included: true },
{ name: t("plan.feature.automation", "Automate tool workflows"), included: true },
{ name: t("plan.feature.api", "Monthly API Credits"), included: true },
{ name: t("plan.feature.priority", "Priority Support"), included: false },
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: false },
],
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
id: "enterprise",
name: t("plan.enterprise.name", "Enterprise"),
price: 0,
currency: '$',
period: '',
currency: "$",
period: "",
isContactOnly: true,
highlights: ENTERPRISE_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
highlights: ENTERPRISE_PLAN_FEATURES.map((f) => t(f.translationKey, f.defaultText)),
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Monthly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: true },
]
}
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
{ name: t("plan.feature.fileSize", "File Size Limit"), included: true },
{ name: t("plan.feature.automation", "Automate tool workflows"), included: true },
{ name: t("plan.feature.api", "Monthly API Credits"), included: true },
{ name: t("plan.feature.priority", "Priority Support"), included: true },
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: true },
],
},
];
}, [t, plans]);
+11 -14
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useFileState, useFileActions } from '@app/contexts/FileContext';
import { downloadFile } from '@app/services/downloadService';
import { useEffect } from "react";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { downloadFile } from "@app/services/downloadService";
/**
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
@@ -14,17 +14,14 @@ export function useSaveShortcut() {
useEffect(() => {
const handleKeyDown = async (event: KeyboardEvent) => {
// Check for Ctrl+S (Windows/Linux) or Cmd+S (Mac)
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
// Get selected files or all files if nothing selected
const selectedFileIds = state.ui.selectedFileIds;
const filesToSave = selectedFileIds.length > 0
? selectors.getFiles(selectedFileIds)
: selectors.getFiles();
const stubsToSave = selectedFileIds.length > 0
? selectors.getStirlingFileStubs(selectedFileIds)
: selectors.getStirlingFileStubs();
const filesToSave = selectedFileIds.length > 0 ? selectors.getFiles(selectedFileIds) : selectors.getFiles();
const stubsToSave =
selectedFileIds.length > 0 ? selectors.getStirlingFileStubs(selectedFileIds) : selectors.getStirlingFileStubs();
if (filesToSave.length === 0) {
return;
@@ -40,14 +37,14 @@ export function useSaveShortcut() {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: stub.localFilePath
localPath: stub.localFilePath,
});
// Mark file as clean after successful save
if (result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false
isDirty: false,
});
}
} catch (error) {
@@ -57,7 +54,7 @@ export function useSaveShortcut() {
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [selectors, state.ui.selectedFileIds, fileActions]);
}
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { useState, useEffect, useRef } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
export interface SelfHostedAuthState {
isSelfHosted: boolean;
@@ -21,13 +21,13 @@ export function useSelfHostedAuth(): SelfHostedAuthState {
const wasSelfHosted = useRef(false);
useEffect(() => {
void connectionModeService.getCurrentMode().then(mode => setIsSelfHosted(mode === 'selfhosted'));
return connectionModeService.subscribeToModeChanges(cfg => setIsSelfHosted(cfg.mode === 'selfhosted'));
void connectionModeService.getCurrentMode().then((mode) => setIsSelfHosted(mode === "selfhosted"));
return connectionModeService.subscribeToModeChanges((cfg) => setIsSelfHosted(cfg.mode === "selfhosted"));
}, []);
useEffect(() => {
void authService.isAuthenticated().then(setIsAuthenticated);
return authService.subscribeToAuth(status => setIsAuthenticated(status === 'authenticated'));
return authService.subscribeToAuth((status) => setIsAuthenticated(status === "authenticated"));
}, []);
useEffect(() => {
@@ -1,8 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { useState, useEffect, useRef } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
/**
* Desktop implementation of useSelfHostedToolAvailability.
@@ -14,9 +14,7 @@ import { endpointAvailabilityService } from '@app/services/endpointAvailabilityS
* - Self-hosted server is online
* - Local backend port is not yet known
*/
export function useSelfHostedToolAvailability(
tools: Array<{ id: string; endpoints?: string[] }>
): Set<string> {
export function useSelfHostedToolAvailability(tools: Array<{ id: string; endpoints?: string[] }>): Set<string> {
const [unavailableIds, setUnavailableIds] = useState<Set<string>>(new Set());
// Keep a stable ref to the latest tools list to avoid unnecessary re-subscriptions
const toolsRef = useRef(tools);
@@ -27,13 +25,13 @@ export function useSelfHostedToolAvailability(
const computeUnavailableTools = async () => {
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'selfhosted') {
if (mode !== "selfhosted") {
setUnavailableIds(new Set());
return;
}
const { status } = selfHostedServerMonitor.getSnapshot();
if (status !== 'offline') {
if (status !== "offline") {
// Idle or checking — not yet confirmed offline; don't mark anything unavailable
if (!cancelled) setUnavailableIds(new Set());
return;
@@ -49,20 +47,18 @@ export function useSelfHostedToolAvailability(
// For each tool, check whether at least one of its endpoints is supported locally
const unavailable = new Set<string>();
await Promise.all(
toolsRef.current.map(async tool => {
toolsRef.current.map(async (tool) => {
const endpoints = tool.endpoints ?? [];
if (endpoints.length === 0) return; // No endpoints → always available
const locallySupported = await Promise.all(
endpoints.map(ep =>
endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl)
)
endpoints.map((ep) => endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl)),
);
if (!locallySupported.some(Boolean)) {
unavailable.add(tool.id);
}
})
}),
);
if (!cancelled) setUnavailableIds(unavailable);
@@ -1,6 +1,6 @@
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useSelfHostedAuth } from '@app/hooks/useSelfHostedAuth';
import type { SharingEnabledResult } from '@core/hooks/useSharingEnabled';
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
import type { SharingEnabledResult } from "@core/hooks/useSharingEnabled";
/**
* Desktop override: file-sharing features require self-hosted mode AND an
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { useState, useEffect } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { tauriBackendService } from "@app/services/tauriBackendService";
/**
* Desktop hook to check if a tool endpoint will use cloud backend
@@ -28,7 +28,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
// Check if in SaaS mode
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'saas') {
if (mode !== "saas") {
setUsesCloud(false);
return;
}
@@ -45,7 +45,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
// Available on SaaS, check if also available locally
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl()
tauriBackendService.getBackendUrl(),
);
// Show cloud badge only if SaaS supports it but local doesn't
setUsesCloud(!supportedLocally);
@@ -59,7 +59,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
if (status === "healthy") {
checkCloudRouting();
}
});
@@ -1,20 +1,23 @@
import { useViewer } from "@app/contexts/ViewerContext"
import { useViewer } from "@app/contexts/ViewerContext";
import { useCallback } from "react";
export function useViewerKeyCommand(): (event: KeyboardEvent) => boolean {
const { rotationActions } = useViewer();
return useCallback((event:KeyboardEvent): boolean => {
switch (event.key) {
case 'r':
case 'R':
event.preventDefault();
if (event.shiftKey) {
rotationActions.rotateBackward();
} else {
rotationActions.rotateForward();
}
return useCallback(
(event: KeyboardEvent): boolean => {
switch (event.key) {
case "r":
case "R":
event.preventDefault();
if (event.shiftKey) {
rotationActions.rotateBackward();
} else {
rotationActions.rotateForward();
}
return true;
}
return false;
}, [rotationActions]);
}
},
[rotationActions],
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { operationRouter } from '@app/services/operationRouter';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { useState, useEffect } from "react";
import { operationRouter } from "@app/services/operationRouter";
import { tauriBackendService } from "@app/services/tauriBackendService";
/**
* Desktop hook to detect if an operation will use cloud/SaaS backend
@@ -29,7 +29,7 @@ export function useWillUseCloud(endpoint?: string): boolean {
const willRoute = await operationRouter.willRouteToSaaS(endpoint);
setWillUseCloud(willRoute);
} catch (error) {
console.error('[useWillUseCloud] Failed to check cloud routing for endpoint:', endpoint, error);
console.error("[useWillUseCloud] Failed to check cloud routing for endpoint:", endpoint, error);
setWillUseCloud(false);
}
};
@@ -39,7 +39,7 @@ export function useWillUseCloud(endpoint?: string): boolean {
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
if (status === "healthy") {
checkCloudRouting();
}
});
+1 -1
View File
@@ -1,4 +1,4 @@
import HomePage from '@app/pages/HomePage';
import HomePage from "@app/pages/HomePage";
/**
* Desktop override of Landing.
+1 -1
View File
@@ -1,4 +1,4 @@
import { Navigate } from 'react-router-dom';
import { Navigate } from "react-router-dom";
/**
* Desktop override of the /login route.
@@ -1,6 +1,6 @@
import { ActionIcon } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { ActionIcon } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
interface LoginHeaderProps {
title: string;
@@ -17,11 +17,15 @@ export default function LoginHeader({ title, subtitle, centerOnly = false, onClo
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 }}>
<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>}
{title && (
<h1 className="login-title" style={{ margin: 0 }}>
{title}
</h1>
)}
</div>
{onClose && (
<ActionIcon
@@ -29,7 +33,7 @@ export default function LoginHeader({ title, subtitle, centerOnly = false, onClo
radius="md"
size={32}
variant="subtle"
style={{ flexShrink: 0, color: 'var(--text-secondary)', outline: 'none' }}
style={{ flexShrink: 0, color: "var(--text-secondary)", outline: "none" }}
>
<CloseIcon fontSize="small" />
</ActionIcon>
+7 -7
View File
@@ -4,16 +4,16 @@
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
*/
import type { AxiosInstance } from 'axios';
import { create } from '@app/services/tauriHttpClient';
import { handleHttpError } from '@app/services/httpErrorHandler';
import { setupApiInterceptors } from '@app/services/apiClientSetup';
import { getApiBaseUrl } from '@app/services/apiClientConfig';
import type { AxiosInstance } from "axios";
import { create } from "@app/services/tauriHttpClient";
import { handleHttpError } from "@app/services/httpErrorHandler";
import { setupApiInterceptors } from "@app/services/apiClientSetup";
import { getApiBaseUrl } from "@app/services/apiClientConfig";
// Create Tauri HTTP client with default config
const apiClient = create({
baseURL: getApiBaseUrl(),
responseType: 'json',
responseType: "json",
withCredentials: false, // Desktop doesn't need credentials
});
@@ -27,7 +27,7 @@ apiClient.interceptors.response.use(
async (error) => {
await handleHttpError(error); // Handle error (shows toast unless suppressed)
return Promise.reject(error);
}
},
);
// ---------- Exports ----------
@@ -1,4 +1,4 @@
import { isTauri } from '@tauri-apps/api/core';
import { isTauri } from "@tauri-apps/api/core";
/**
* Desktop override: Determine base URL depending on Tauri environment
@@ -15,7 +15,7 @@ import { isTauri } from '@tauri-apps/api/core';
export function getApiBaseUrl(): string {
if (!isTauri()) {
// Runtime override to fix hardcoded localhost in builds
if (typeof window !== 'undefined' && (window as any).STIRLING_PDF_API_BASE_URL) {
if (typeof window !== "undefined" && (window as any).STIRLING_PDF_API_BASE_URL) {
return (window as any).STIRLING_PDF_API_BASE_URL;
}
@@ -26,5 +26,5 @@ export function getApiBaseUrl(): string {
// The actual URL will be set dynamically by operationRouter based on:
// - Offline mode: dynamic port from tauriBackendService
// - Server mode: configured server URL from connectionModeService
return '';
return "";
}
+39 -33
View File
@@ -1,14 +1,14 @@
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { alert } from '@app/components/toast';
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { createBackendNotReadyError } from '@app/constants/backendErrors';
import { operationRouter } from '@app/services/operationRouter';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
import i18n from '@app/i18n';
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
import { alert } from "@app/components/toast";
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { createBackendNotReadyError } from "@app/constants/backendErrors";
import { operationRouter } from "@app/services/operationRouter";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import i18n from "@app/i18n";
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
@@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
const baseUrl = await operationRouter.getBaseUrl(originalUrl);
// Build the full URL
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
if (extendedConfig.url && !extendedConfig.url.startsWith("http")) {
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
}
@@ -67,7 +67,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Tag request so error handler can identify SaaS backend errors without URL matching
extendedConfig._isSaaSRequest = isSaaSBackendRequest;
console.debug(`[apiClientSetup] Auth check: isRemote=${isRemote}, isSaaSBackendRequest=${isSaaSBackendRequest}, needsAuth=${needsAuth}, baseUrl=${baseUrl}`);
console.debug(
`[apiClientSetup] Auth check: isRemote=${isRemote}, isSaaSBackendRequest=${isSaaSBackendRequest}, needsAuth=${needsAuth}, baseUrl=${baseUrl}`,
);
if (needsAuth) {
// Enable credentials for session management
@@ -88,7 +90,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
extendedConfig.withCredentials = false;
}
} catch (error) {
console.error('[apiClientSetup] Error in request interceptor:', error);
console.error("[apiClientSetup] Error in request interceptor:", error);
// Continue with request even if routing/auth logic fails
// This ensures requests aren't blocked by interceptor errors
}
@@ -99,18 +101,20 @@ export function setupApiInterceptors(client: AxiosInstance): void {
const backendStatus = tauriBackendService.getBackendStatus();
const backendPort = tauriBackendService.getBackendPort();
console.debug(`[apiClientSetup] Backend readiness check for ${extendedConfig.url}: isSaaS=${isSaaS}, skipCheck=${skipCheck}, skipForSaaSBackend=${skipForSaaSBackend}, backendHealthy=${backendHealthy}, backendStatus=${backendStatus}, backendPort=${backendPort}`);
console.debug(
`[apiClientSetup] Backend readiness check for ${extendedConfig.url}: isSaaS=${isSaaS}, skipCheck=${skipCheck}, skipForSaaSBackend=${skipForSaaSBackend}, backendHealthy=${backendHealthy}, backendStatus=${backendStatus}, backendPort=${backendPort}`,
);
if (isSaaS && !skipCheck && !skipForSaaSBackend && !backendHealthy) {
const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') {
const method = (extendedConfig.method || "get").toLowerCase();
if (method !== "get") {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
alertType: "error",
title: i18n.t("backendHealth.offline", "Backend Offline"),
body: i18n.t("backendHealth.wait", "Please wait for the backend to finish launching and try again."),
isPersistentPopup: false,
});
}
@@ -120,7 +124,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
return extendedConfig;
},
(error) => Promise.reject(error)
(error) => Promise.reject(error),
);
// Response interceptor: Handle auth errors and update credits from headers
@@ -128,16 +132,18 @@ export function setupApiInterceptors(client: AxiosInstance): void {
async (response) => {
// Check for credit balance update in response headers
// Backend includes X-Credits-Remaining header after operations that consume credits
const creditsHeader = response.headers['x-credits-remaining'];
const creditsHeader = response.headers["x-credits-remaining"];
if (creditsHeader !== undefined && creditsHeader !== null) {
const creditsRemaining = parseInt(creditsHeader, 10);
if (!isNaN(creditsRemaining)) {
// Dispatch event with new balance for immediate update
window.dispatchEvent(new CustomEvent('credits:updated', {
detail: { creditsRemaining }
}));
window.dispatchEvent(
new CustomEvent("credits:updated", {
detail: { creditsRemaining },
}),
);
}
}
@@ -145,8 +151,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
},
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;
const requestUrl = String(originalRequest?.url || '');
const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
const requestUrl = String(originalRequest?.url || "");
const isAuthProbeRequest = requestUrl.includes("/api/v1/auth/me");
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
@@ -155,8 +161,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
if (isAuthProbeRequest) {
return Promise.reject(error);
}
if (typeof window !== 'undefined') {
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
if (typeof window !== "undefined") {
console.warn("[apiClientSetup] 401 on path:", window.location.pathname, "url:", originalRequest.url);
}
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
@@ -206,14 +212,14 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Handle 403 Forbidden - unauthorized access
if (error.response?.status === 403) {
alert({
alertType: 'error',
title: i18n.t('auth.accessDenied', 'Access Denied'),
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
alertType: "error",
title: i18n.t("auth.accessDenied", "Access Denied"),
body: i18n.t("auth.insufficientPermissions", "You do not have permission to perform this action."),
isPersistentPopup: false,
});
}
return Promise.reject(error);
}
},
);
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { invoke } from "@tauri-apps/api/core";
const TOKEN_KEY = 'stirling_jwt';
const TOKEN_KEY = "stirling_jwt";
/**
* Read auth token from any available source (Tauri store or localStorage).
@@ -9,19 +9,19 @@ const TOKEN_KEY = 'stirling_jwt';
export async function getAuthTokenFromAnySource(): Promise<string | null> {
// Try Tauri store first
try {
const token = await invoke<string | null>('get_auth_token');
const token = await invoke<string | null>("get_auth_token");
if (token) {
return token;
}
} catch (error) {
console.error('[Desktop AuthTokenStore] Failed to read from Tauri store:', error);
console.error("[Desktop AuthTokenStore] Failed to read from Tauri store:", error);
}
// Fallback to localStorage
try {
return localStorage.getItem(TOKEN_KEY);
} catch (error) {
console.error('[Desktop AuthTokenStore] Failed to read from localStorage:', error);
console.error("[Desktop AuthTokenStore] Failed to read from localStorage:", error);
return null;
}
}
@@ -1,6 +1,6 @@
import i18n from '@app/i18n';
import { tauriBackendService } from '@app/services/tauriBackendService';
import type { BackendHealthState } from '@app/types/backendHealth';
import i18n from "@app/i18n";
import { tauriBackendService } from "@app/services/tauriBackendService";
import type { BackendHealthState } from "@app/types/backendHealth";
type Listener = (state: BackendHealthState) => void;
@@ -11,7 +11,7 @@ class BackendHealthMonitor {
private state: BackendHealthState = {
status: tauriBackendService.getBackendStatus(),
error: null,
isOnline: tauriBackendService.getBackendStatus() === 'healthy',
isOnline: tauriBackendService.getBackendStatus() === "healthy",
};
constructor(pollingInterval = 5000) {
@@ -21,10 +21,11 @@ class BackendHealthMonitor {
tauriBackendService.subscribeToStatus((status) => {
this.updateState({
status,
error: status === 'healthy' ? null : this.state.error,
message: status === 'healthy'
? i18n.t('backendHealth.online', 'Backend Online')
: this.state.message ?? i18n.t('backendHealth.offline', 'Backend Offline'),
error: status === "healthy" ? null : this.state.error,
message:
status === "healthy"
? i18n.t("backendHealth.online", "Backend Online")
: (this.state.message ?? i18n.t("backendHealth.offline", "Backend Offline")),
});
});
}
@@ -35,7 +36,7 @@ class BackendHealthMonitor {
...this.state,
...partial,
status: nextStatus,
isOnline: nextStatus === 'healthy',
isOnline: nextStatus === "healthy",
};
// Only notify listeners if meaningful state changed
@@ -73,24 +74,24 @@ class BackendHealthMonitor {
const healthy = await tauriBackendService.checkBackendHealth();
if (healthy) {
this.updateState({
status: 'healthy',
message: i18n.t('backendHealth.online', 'Backend Online'),
status: "healthy",
message: i18n.t("backendHealth.online", "Backend Online"),
error: null,
});
} else {
this.updateState({
status: 'unhealthy',
message: i18n.t('backendHealth.offline', 'Backend Offline'),
error: i18n.t('backendHealth.offline', 'Backend Offline'),
status: "unhealthy",
message: i18n.t("backendHealth.offline", "Backend Offline"),
error: i18n.t("backendHealth.offline", "Backend Offline"),
});
}
return healthy;
} catch (error) {
console.error('[BackendHealthMonitor] Health check failed:', error);
console.error("[BackendHealthMonitor] Health check failed:", error);
this.updateState({
status: 'unhealthy',
message: 'Backend is unavailable',
error: 'Backend offline',
status: "unhealthy",
message: "Backend is unavailable",
error: "Backend offline",
});
return false;
}
@@ -1,9 +1,9 @@
import i18n from '@app/i18n';
import { alert } from '@app/components/toast';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { operationRouter } from '@app/services/operationRouter';
import { connectionModeService } from '@app/services/connectionModeService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import i18n from "@app/i18n";
import { alert } from "@app/components/toast";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { operationRouter } from "@app/services/operationRouter";
import { connectionModeService } from "@app/services/connectionModeService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
@@ -27,28 +27,25 @@ export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
if (endpoint) {
const skipCheck = await operationRouter.shouldSkipBackendReadyCheck(endpoint);
if (skipCheck) {
console.debug('[backendReadinessGuard] Skipping backend ready check (SaaS routing)');
console.debug("[backendReadinessGuard] Skipping backend ready check (SaaS routing)");
return true;
}
}
const mode = await connectionModeService.getCurrentMode();
if (mode === 'selfhosted') {
if (mode === "selfhosted") {
let { status } = selfHostedServerMonitor.getSnapshot();
// 'checking' means the first poll hasn't returned yet. Wait briefly (up to
// 1.5 s) for it to resolve so we don't surface raw network errors during the
// first few seconds after launch. If it doesn't resolve in time we fall
// through and allow the operation — the HTTP layer will handle any error.
if (status === 'checking') {
await Promise.race([
selfHostedServerMonitor.checkNow(),
new Promise<void>(resolve => setTimeout(resolve, 1500)),
]);
if (status === "checking") {
await Promise.race([selfHostedServerMonitor.checkNow(), new Promise<void>((resolve) => setTimeout(resolve, 1500))]);
status = selfHostedServerMonitor.getSnapshot().status;
}
if (status === 'offline') {
if (status === "offline") {
// Server offline: allow through if local backend port is known.
// operationRouter will route to local for supported endpoints.
// Suppress the toast — SelfHostedOfflineBanner communicates the outage.
@@ -73,9 +70,9 @@ export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.checking', 'Checking backend status...'),
alertType: "error",
title: i18n.t("backendHealth.offline", "Backend Offline"),
body: i18n.t("backendHealth.checking", "Checking backend status..."),
isPersistentPopup: false,
});
}
@@ -1,9 +1,9 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { invoke } from "@tauri-apps/api/core";
import { fetch } from "@tauri-apps/plugin-http";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
export type ConnectionMode = 'saas' | 'selfhosted' | 'local';
export type ConnectionMode = "saas" | "selfhosted" | "local";
export interface SSOProviderConfig {
id: string;
@@ -37,8 +37,8 @@ export interface ConnectionTestResult {
diagnostics?: DiagnosticResult[];
}
export const LOCAL_MODE_STORAGE_KEY = 'stirling-local-mode';
export const JWT_EXPIRED_PROMPTED_KEY = 'stirling-jwt-expired-prompted';
export const LOCAL_MODE_STORAGE_KEY = "stirling-local-mode";
export const JWT_EXPIRED_PROMPTED_KEY = "stirling-jwt-expired-prompted";
export class ConnectionModeService {
private static instance: ConnectionModeService;
@@ -57,7 +57,7 @@ export class ConnectionModeService {
if (!this.configLoadedOnce) {
await this.loadConfig();
}
return this.currentConfig || { mode: 'saas', server_config: null, lock_connection_mode: false };
return this.currentConfig || { mode: "saas", server_config: null, lock_connection_mode: false };
}
async getCurrentMode(): Promise<ConnectionMode> {
@@ -79,24 +79,24 @@ export class ConnectionModeService {
private notifyListeners() {
if (this.currentConfig) {
this.modeListeners.forEach(listener => listener(this.currentConfig!));
this.modeListeners.forEach((listener) => listener(this.currentConfig!));
}
}
private async loadConfig(): Promise<void> {
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 (localFlag === 'true') {
if (localFlag === "true") {
// User previously chose local-only mode (signed out or explicitly went offline).
// Applies to both 'saas' and 'selfhosted' store modes — the Rust guard on locked
// deployments can't change 'selfhosted' to 'saas' in the store, so we check the
// flag regardless of what the store says.
config.mode = 'local';
config.mode = "local";
} else if (
config.mode === 'saas' &&
config.mode === "saas" &&
config.server_config === null &&
!config.lock_connection_mode &&
localFlag === null
@@ -108,59 +108,63 @@ export class ConnectionModeService {
// 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';
config.mode = "local";
}
this.currentConfig = config;
this.configLoadedOnce = true;
} catch (error) {
console.error('Failed to load connection config:', error);
console.error("Failed to load connection config:", error);
// Default to local mode on error — safer than showing SaaS UI for a
// desktop app whose bundled backend is always available.
this.currentConfig = { mode: 'local', server_config: null, lock_connection_mode: false };
this.currentConfig = { mode: "local", server_config: null, lock_connection_mode: false };
this.configLoadedOnce = true;
}
}
async switchToSaaS(saasServerUrl: string): Promise<void> {
if (this.currentConfig?.lock_connection_mode) {
throw new Error('Connection mode is locked by provisioning');
throw new Error("Connection mode is locked by provisioning");
}
// Clear local-only flag and expiry-prompted flag when signing in
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
console.log('Switching to SaaS mode');
console.log("Switching to SaaS mode");
const serverConfig: ServerConfig = { url: saasServerUrl };
await invoke('set_connection_mode', {
mode: 'saas',
await invoke("set_connection_mode", {
mode: "saas",
serverConfig,
});
this.currentConfig = { mode: 'saas', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
this.currentConfig = {
mode: "saas",
server_config: serverConfig,
lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false,
};
// Clear endpoint availability cache when mode changes
endpointAvailabilityService.clearCache();
console.log('Cleared endpoint availability cache due to connection mode change');
console.log("Cleared endpoint availability cache due to connection mode change");
this.notifyListeners();
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');
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');
localStorage.setItem(LOCAL_MODE_STORAGE_KEY, "true");
await invoke('set_connection_mode', {
mode: 'saas',
await invoke("set_connection_mode", {
mode: "saas",
serverConfig: null,
});
@@ -169,7 +173,7 @@ export class ConnectionModeService {
const isLocked = this.currentConfig?.lock_connection_mode ?? false;
const preservedServerConfig = isLocked ? (this.currentConfig?.server_config ?? null) : null;
this.currentConfig = { mode: 'local', server_config: preservedServerConfig, lock_connection_mode: isLocked };
this.currentConfig = { mode: "local", server_config: preservedServerConfig, lock_connection_mode: isLocked };
// Clear endpoint availability cache when mode changes
endpointAvailabilityService.clearCache();
@@ -182,18 +186,22 @@ export class ConnectionModeService {
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
console.log('Switching to self-hosted mode:', serverConfig);
console.log("Switching to self-hosted mode:", serverConfig);
await invoke('set_connection_mode', {
mode: 'selfhosted',
await invoke("set_connection_mode", {
mode: "selfhosted",
serverConfig,
});
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
this.currentConfig = {
mode: "selfhosted",
server_config: serverConfig,
lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false,
};
// Clear endpoint availability cache when mode changes
endpointAvailabilityService.clearCache();
console.log('Cleared endpoint availability cache due to connection mode change');
console.log("Cleared endpoint availability cache due to connection mode change");
// Single authoritative calling point for health monitoring — every path that
// switches to self-hosted mode funnels through here.
@@ -201,7 +209,7 @@ export class ConnectionModeService {
this.notifyListeners();
console.log('Switched to self-hosted mode successfully');
console.log("Switched to self-hosted mode successfully");
}
/**
@@ -215,10 +223,10 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] - User Agent: ${navigator.userAgent}`);
console.log(`[ConnectionModeService] - Platform: ${navigator.platform}`);
console.log(`[ConnectionModeService] - Online: ${navigator.onLine}`);
console.log(`[ConnectionModeService] - Connection Type: ${(navigator as any).connection?.effectiveType || 'unknown'}`);
console.log(`[ConnectionModeService] - Connection Type: ${(navigator as any).connection?.effectiveType || "unknown"}`);
console.log(`[ConnectionModeService] - Language: ${navigator.language}`);
console.log(`[ConnectionModeService] - Cookies Enabled: ${navigator.cookieEnabled}`);
console.log(`[ConnectionModeService] - Hardware Concurrency: ${navigator.hardwareConcurrency || 'unknown'} cores`);
console.log(`[ConnectionModeService] - Hardware Concurrency: ${navigator.hardwareConcurrency || "unknown"} cores`);
console.log(`[ConnectionModeService] - Max Touch Points: ${navigator.maxTouchPoints}`);
// Check for proxy environment variables
@@ -238,10 +246,11 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] - window.location.protocol:`, window.location.protocol);
// Tauri v2 detection: check for __TAURI_INTERNALS__ or tauri:// protocol
const isTauriV2 = typeof (window as any).__TAURI_INTERNALS__ !== 'undefined' ||
window.location.protocol === 'tauri:' ||
window.location.hostname === 'tauri.localhost';
const isTauriV1 = typeof (window as any).__TAURI__ !== 'undefined';
const isTauriV2 =
typeof (window as any).__TAURI_INTERNALS__ !== "undefined" ||
window.location.protocol === "tauri:" ||
window.location.hostname === "tauri.localhost";
const isTauriV1 = typeof (window as any).__TAURI__ !== "undefined";
const isTauri = isTauriV1 || isTauriV2;
console.log(`[ConnectionModeService] - Running in Tauri v1: ${isTauriV1}`);
@@ -261,22 +270,22 @@ export class ConnectionModeService {
}
const diagnostics: DiagnosticResult[] = [];
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
const healthUrl = `${url.replace(/\/$/, "")}/api/v1/info/status`;
const isLocal = this.isLocalAddress(url);
const isHttpUrl = url.startsWith('http://');
const isHttpsUrl = url.startsWith('https://');
const isHttpUrl = url.startsWith("http://");
const isHttpsUrl = url.startsWith("https://");
console.log(`[ConnectionModeService] Connection Parameters:`);
console.log(`[ConnectionModeService] - Target URL: ${url}`);
console.log(`[ConnectionModeService] - Health endpoint: ${healthUrl}`);
console.log(`[ConnectionModeService] - Is local address: ${isLocal}`);
console.log(`[ConnectionModeService] - Protocol: ${isHttpUrl ? 'HTTP' : isHttpsUrl ? 'HTTPS' : 'Unknown'}`);
console.log(`[ConnectionModeService] - Protocol: ${isHttpUrl ? "HTTP" : isHttpsUrl ? "HTTPS" : "Unknown"}`);
console.log(`[ConnectionModeService] ================================================================`);
// STAGE 1: Test the protocol they specified
if (isHttpUrl) {
console.log(`[ConnectionModeService] Stage 1: Testing HTTP (as specified in URL)`);
const stage1Result = await this.testHTTP(healthUrl, 'Stage 1: HTTP (as specified)');
const stage1Result = await this.testHTTP(healthUrl, "Stage 1: HTTP (as specified)");
diagnostics.push(stage1Result);
if (stage1Result.success) {
@@ -297,15 +306,15 @@ export class ConnectionModeService {
// HTTP failed, try HTTPS as fallback
console.log(`[ConnectionModeService] Stage 2: HTTP failed, trying HTTPS`);
const httpsUrl = healthUrl.replace('http://', 'https://');
const stage2Result = await this.testHTTPS(httpsUrl, 'Stage 2: Trying HTTPS', false);
const httpsUrl = healthUrl.replace("http://", "https://");
const stage2Result = await this.testHTTPS(httpsUrl, "Stage 2: Trying HTTPS", false);
diagnostics.push(stage2Result);
if (stage2Result.success) {
return {
success: false,
error: 'Server is only accessible via HTTPS, not HTTP.',
errorCode: 'HTTP_NOT_AVAILABLE',
error: "Server is only accessible via HTTPS, not HTTP.",
errorCode: "HTTP_NOT_AVAILABLE",
diagnostics,
};
}
@@ -314,7 +323,7 @@ export class ConnectionModeService {
} else {
// HTTPS URL or no protocol - test HTTPS
console.log(`[ConnectionModeService] Stage 1: Testing HTTPS with full certificate validation`);
const stage1Result = await this.testHTTPS(healthUrl, 'Stage 1: Standard HTTPS', false);
const stage1Result = await this.testHTTPS(healthUrl, "Stage 1: Standard HTTPS", false);
diagnostics.push(stage1Result);
if (stage1Result.success) {
@@ -332,7 +341,7 @@ export class ConnectionModeService {
// STAGE 2: Test with certificate validation disabled (diagnose cert issues)
console.log(`[ConnectionModeService] Stage 2: Testing HTTPS with certificate validation disabled`);
const stage2Result = await this.testHTTPS(healthUrl, 'Stage 2: HTTPS (no cert validation)', true);
const stage2Result = await this.testHTTPS(healthUrl, "Stage 2: HTTPS (no cert validation)", true);
diagnostics.push(stage2Result);
if (stage2Result.success) {
@@ -351,16 +360,16 @@ export class ConnectionModeService {
// STAGE 3: Try HTTP instead (for local/internal servers)
console.log(`[ConnectionModeService] Stage 3: Testing HTTP instead of HTTPS`);
const httpUrl = healthUrl.replace('https://', 'http://');
const stage3Result = await this.testHTTP(httpUrl, 'Stage 3: HTTP (unencrypted)');
const httpUrl = healthUrl.replace("https://", "http://");
const stage3Result = await this.testHTTP(httpUrl, "Stage 3: HTTP (unencrypted)");
diagnostics.push(stage3Result);
if (stage3Result.success) {
console.log(`[ConnectionModeService] ⚠️ HTTP works but HTTPS doesn't`);
return {
success: false,
error: 'Server is only accessible via HTTP (not HTTPS).',
errorCode: 'HTTPS_NOT_AVAILABLE',
error: "Server is only accessible via HTTP (not HTTPS).",
errorCode: "HTTPS_NOT_AVAILABLE",
diagnostics,
};
}
@@ -398,8 +407,8 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] ❌ No external connectivity - network/firewall issue`);
return {
success: false,
error: 'No internet connectivity detected. All network requests are failing.',
errorCode: 'NETWORK_BLOCKED',
error: "No internet connectivity detected. All network requests are failing.",
errorCode: "NETWORK_BLOCKED",
diagnostics,
};
}
@@ -415,12 +424,12 @@ export class ConnectionModeService {
const stage6Result = await this.testStage6_DNSResolution(urlObj.hostname);
diagnostics.push(stage6Result);
if (!stage6Result.success && stage6Result.message.includes('DNS lookup failed')) {
if (!stage6Result.success && stage6Result.message.includes("DNS lookup failed")) {
console.log(`[ConnectionModeService] ❌ DNS resolution failed for target server`);
return {
success: false,
error: `Cannot resolve hostname: ${urlObj.hostname}`,
errorCode: 'DNS_RESOLUTION_FAILED',
errorCode: "DNS_RESOLUTION_FAILED",
diagnostics,
};
}
@@ -434,8 +443,8 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] ⚠️ HEAD method works but GET doesn't - unusual server behavior`);
return {
success: false,
error: 'Server responds to HEAD requests but not GET requests.',
errorCode: 'METHOD_MISMATCH',
error: "Server responds to HEAD requests but not GET requests.",
errorCode: "METHOD_MISMATCH",
diagnostics,
};
}
@@ -449,8 +458,8 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] ⚠️ Works with browser User-Agent - server may be blocking desktop apps`);
return {
success: false,
error: 'Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.',
errorCode: 'USER_AGENT_BLOCKED',
error: "Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.",
errorCode: "USER_AGENT_BLOCKED",
diagnostics,
};
}
@@ -459,20 +468,19 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] ❌ Server unreachable - all diagnostic tests failed`);
// Analyze timing patterns
const avgDuration = diagnostics
.filter(d => !d.success && d.duration)
.reduce((sum, d) => sum + (d.duration || 0), 0) /
diagnostics.filter(d => !d.success && d.duration).length;
const avgDuration =
diagnostics.filter((d) => !d.success && d.duration).reduce((sum, d) => sum + (d.duration || 0), 0) /
diagnostics.filter((d) => !d.success && d.duration).length;
// Log comprehensive diagnostic summary
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
console.log(`[ConnectionModeService] Total tests run: ${diagnostics.length}`);
console.log(`[ConnectionModeService] Passed: ${diagnostics.filter(d => d.success).length}`);
console.log(`[ConnectionModeService] Failed: ${diagnostics.filter(d => !d.success).length}`);
console.log(`[ConnectionModeService] Passed: ${diagnostics.filter((d) => d.success).length}`);
console.log(`[ConnectionModeService] Failed: ${diagnostics.filter((d) => !d.success).length}`);
console.log(`[ConnectionModeService] Average failure time: ${avgDuration.toFixed(0)}ms`);
console.log(`[ConnectionModeService] ---------------------------------------------------------------`);
diagnostics.forEach((diag) => {
const icon = diag.success ? '✅' : '❌';
const icon = diag.success ? "✅" : "❌";
console.log(`[ConnectionModeService] ${icon} ${diag.stage}: ${diag.message} (${diag.duration}ms)`);
});
console.log(`[ConnectionModeService] ================================================================`);
@@ -480,9 +488,13 @@ export class ConnectionModeService {
// Log timing-based analysis
if (avgDuration < 100) {
console.log(`[ConnectionModeService] Analysis: Immediate rejections (<${avgDuration.toFixed(0)}ms) suggest firewall/antivirus blocking`);
console.log(
`[ConnectionModeService] Analysis: Immediate rejections (<${avgDuration.toFixed(0)}ms) suggest firewall/antivirus blocking`,
);
} else if (avgDuration > 5000) {
console.log(`[ConnectionModeService] Analysis: Timeouts (avg ${(avgDuration/1000).toFixed(1)}s) suggest server not responding or network route blocked`);
console.log(
`[ConnectionModeService] Analysis: Timeouts (avg ${(avgDuration / 1000).toFixed(1)}s) suggest server not responding or network route blocked`,
);
} else {
console.log(`[ConnectionModeService] Analysis: Server may be down, blocking connections, or behind a firewall`);
}
@@ -491,8 +503,8 @@ export class ConnectionModeService {
return {
success: false,
error: 'Cannot connect to server. Internet works but this specific server is unreachable.',
errorCode: 'SERVER_UNREACHABLE',
error: "Cannot connect to server. Internet works but this specific server is unreachable.",
errorCode: "SERVER_UNREACHABLE",
diagnostics,
};
}
@@ -502,13 +514,13 @@ export class ConnectionModeService {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.16.') ||
hostname.endsWith('.local')
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
hostname.startsWith("172.16.") ||
hostname.endsWith(".local")
);
} catch {
return false;
@@ -519,10 +531,10 @@ export class ConnectionModeService {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
console.log(`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? 'DISABLED' : 'ENABLED'}`);
console.log(`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? "DISABLED" : "ENABLED"}`);
const fetchOptions: any = {
method: 'GET',
method: "GET",
connectTimeout: 10000,
};
@@ -544,8 +556,8 @@ export class ConnectionModeService {
stage: stageName,
success: true,
message: disableCertValidation
? 'Connected successfully when certificate validation disabled'
: 'Successfully connected with full certificate validation',
? "Connected successfully when certificate validation disabled"
: "Successfully connected with full certificate validation",
duration,
};
}
@@ -565,9 +577,12 @@ export class ConnectionModeService {
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
// Log full error object structure for debugging
if (error && typeof error === 'object') {
if (error && typeof error === "object") {
console.error(`[ConnectionModeService] - Error keys:`, Object.keys(error));
console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
console.error(
`[ConnectionModeService] - Error object:`,
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
);
}
// Categorize error type
@@ -577,20 +592,27 @@ export class ConnectionModeService {
let detailedMessage = `Failed: ${errorMsg}`;
// Check for TLS version mismatch (TLS 1.0/1.1 not supported)
if (errorLower.includes('peer is incompatible') ||
errorLower.includes('protocol version') ||
errorLower.includes('peerincompatible') ||
(errorLower.includes('handshake') && (errorLower.includes('tls') || errorLower.includes('ssl')))) {
if (
errorLower.includes("peer is incompatible") ||
errorLower.includes("protocol version") ||
errorLower.includes("peerincompatible") ||
(errorLower.includes("handshake") && (errorLower.includes("tls") || errorLower.includes("ssl")))
) {
detailedMessage = `TLS version not supported - Server appears to use TLS 1.0 or 1.1 (desktop app requires TLS 1.2+). Please upgrade your server's TLS configuration or use the web version.`;
} else if (errorLower.includes('timeout') || errorLower.includes('timed out')) {
} else if (errorLower.includes("timeout") || errorLower.includes("timed out")) {
detailedMessage = `Timeout after ${duration}ms - server not responding`;
} else if (errorLower.includes('certificate') || errorLower.includes('cert') || errorLower.includes('ssl') || errorLower.includes('tls')) {
} else if (
errorLower.includes("certificate") ||
errorLower.includes("cert") ||
errorLower.includes("ssl") ||
errorLower.includes("tls")
) {
detailedMessage = `SSL/TLS error - ${errorMsg}`;
} else if (errorLower.includes('connection refused') || errorLower.includes('econnrefused')) {
} else if (errorLower.includes("connection refused") || errorLower.includes("econnrefused")) {
detailedMessage = `Connection refused - server may not be running`;
} else if (errorLower.includes('network') || errorLower.includes('dns') || errorLower.includes('enotfound')) {
} else if (errorLower.includes("network") || errorLower.includes("dns") || errorLower.includes("enotfound")) {
detailedMessage = `Network error - ${errorMsg}`;
} else if (errorLower.includes('blocked') || errorLower.includes('filtered')) {
} else if (errorLower.includes("blocked") || errorLower.includes("filtered")) {
detailedMessage = `Request blocked - possible firewall/antivirus`;
} else if (duration < 100) {
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
@@ -613,7 +635,7 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
const response = await fetch(url, {
method: 'GET',
method: "GET",
connectTimeout: 10000,
});
const duration = Date.now() - startTime;
@@ -624,7 +646,7 @@ export class ConnectionModeService {
return {
stage: stageName,
success: true,
message: 'Successfully connected using HTTP',
message: "Successfully connected using HTTP",
duration,
};
}
@@ -643,8 +665,11 @@ export class ConnectionModeService {
console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`);
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
if (error && typeof error === 'object') {
console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
if (error && typeof error === "object") {
console.error(
`[ConnectionModeService] - Error object:`,
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
);
}
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -652,7 +677,7 @@ export class ConnectionModeService {
let detailedMessage = `Failed: ${errorMsg}`;
if (errorLower.includes('timeout') || errorLower.includes('timed out')) {
if (errorLower.includes("timeout") || errorLower.includes("timed out")) {
detailedMessage = `Timeout after ${duration}ms - server not responding`;
} else if (duration < 100) {
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
@@ -673,14 +698,14 @@ export class ConnectionModeService {
const startTime = Date.now();
try {
const response = await fetch(url, {
method: 'GET',
method: "GET",
connectTimeout: 30000, // 30 seconds
});
const duration = Date.now() - startTime;
if (response.ok) {
return {
stage: 'Stage 4: Extended timeout (30s)',
stage: "Stage 4: Extended timeout (30s)",
success: true,
message: `Connected after ${duration}ms (slow connection)`,
duration,
@@ -688,16 +713,16 @@ export class ConnectionModeService {
}
return {
stage: 'Stage 4: Extended timeout (30s)',
stage: "Stage 4: Extended timeout (30s)",
success: false,
message: `Server returned HTTP ${response.status}`,
duration,
};
} catch (error) {
const duration = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
const errorMsg = error instanceof Error ? error.message : "Unknown error";
return {
stage: 'Stage 4: Extended timeout (30s)',
stage: "Stage 4: Extended timeout (30s)",
success: false,
message: `Failed: ${errorMsg}`,
duration,
@@ -711,25 +736,27 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] 🌐 Stage 5A: Testing external connectivity (google.com)`);
// Test connectivity to a reliable external service
const response = await fetch('https://www.google.com', {
method: 'HEAD',
const response = await fetch("https://www.google.com", {
method: "HEAD",
connectTimeout: 5000,
});
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ Stage 5A: External connectivity confirmed - HTTP ${response.status} (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ Stage 5A: External connectivity confirmed - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok || response.status === 301 || response.status === 302) {
return {
stage: 'Stage 5A: External (google.com)',
stage: "Stage 5A: External (google.com)",
success: true,
message: 'Internet connectivity confirmed via google.com',
message: "Internet connectivity confirmed via google.com",
duration,
};
}
return {
stage: 'Stage 5A: External (google.com)',
stage: "Stage 5A: External (google.com)",
success: false,
message: `Unexpected response from google.com: ${response.status}`,
duration,
@@ -741,7 +768,7 @@ export class ConnectionModeService {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
stage: 'Stage 5A: External (google.com)',
stage: "Stage 5A: External (google.com)",
success: false,
message: `Failed: ${errorMsg}`,
duration,
@@ -754,25 +781,27 @@ export class ConnectionModeService {
try {
console.log(`[ConnectionModeService] 🌐 Stage 5B: Testing alternative endpoint (cloudflare.com)`);
const response = await fetch('https://1.1.1.1', {
method: 'HEAD',
const response = await fetch("https://1.1.1.1", {
method: "HEAD",
connectTimeout: 5000,
});
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok || response.status === 301 || response.status === 302 || response.status === 403) {
return {
stage: 'Stage 5B: External (cloudflare)',
stage: "Stage 5B: External (cloudflare)",
success: true,
message: 'Alternative endpoint (1.1.1.1) reachable',
message: "Alternative endpoint (1.1.1.1) reachable",
duration,
};
}
return {
stage: 'Stage 5B: External (cloudflare)',
stage: "Stage 5B: External (cloudflare)",
success: false,
message: `Unexpected response: ${response.status}`,
duration,
@@ -784,7 +813,7 @@ export class ConnectionModeService {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
stage: 'Stage 5B: External (cloudflare)',
stage: "Stage 5B: External (cloudflare)",
success: false,
message: `Failed: ${errorMsg}`,
duration,
@@ -798,8 +827,8 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] 🌐 Stage 5C: Testing HTTP external endpoint (httpbin.org)`);
// Try HTTP (not HTTPS) to see if TLS/SSL is the issue
const response = await fetch('http://httpbin.org/status/200', {
method: 'GET',
const response = await fetch("http://httpbin.org/status/200", {
method: "GET",
connectTimeout: 5000,
});
const duration = Date.now() - startTime;
@@ -808,15 +837,15 @@ export class ConnectionModeService {
if (response.ok) {
return {
stage: 'Stage 5C: External HTTP (no TLS)',
stage: "Stage 5C: External HTTP (no TLS)",
success: true,
message: 'HTTP (unencrypted) connectivity works',
message: "HTTP (unencrypted) connectivity works",
duration,
};
}
return {
stage: 'Stage 5C: External HTTP (no TLS)',
stage: "Stage 5C: External HTTP (no TLS)",
success: false,
message: `Unexpected response: ${response.status}`,
duration,
@@ -828,7 +857,7 @@ export class ConnectionModeService {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
stage: 'Stage 5C: External HTTP (no TLS)',
stage: "Stage 5C: External HTTP (no TLS)",
success: false,
message: `Failed: ${errorMsg}`,
duration,
@@ -845,7 +874,7 @@ export class ConnectionModeService {
// If DNS fails, we'll get an immediate error
const testUrl = `https://${hostname}`;
await fetch(testUrl, {
method: 'HEAD',
method: "HEAD",
connectTimeout: 3000,
});
const duration = Date.now() - startTime;
@@ -853,7 +882,7 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] ✅ Stage 6: DNS resolved successfully (${duration}ms)`);
return {
stage: 'Stage 6: DNS resolution',
stage: "Stage 6: DNS resolution",
success: true,
message: `DNS resolution successful for ${hostname}`,
duration,
@@ -867,9 +896,9 @@ export class ConnectionModeService {
console.error(`[ConnectionModeService] - Error:`, errorMsg);
// Check if it's a DNS-specific error
if (errorLower.includes('dns') || errorLower.includes('enotfound') || errorLower.includes('getaddrinfo')) {
if (errorLower.includes("dns") || errorLower.includes("enotfound") || errorLower.includes("getaddrinfo")) {
return {
stage: 'Stage 6: DNS resolution',
stage: "Stage 6: DNS resolution",
success: false,
message: `DNS lookup failed - cannot resolve ${hostname}`,
duration,
@@ -878,7 +907,7 @@ export class ConnectionModeService {
// If we got here, DNS might be working but connection failed for other reasons
return {
stage: 'Stage 6: DNS resolution',
stage: "Stage 6: DNS resolution",
success: false,
message: `DNS test inconclusive: ${errorMsg}`,
duration,
@@ -892,7 +921,7 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] 🔗 Stage 7: Testing with HEAD method`);
const response = await fetch(url, {
method: 'HEAD',
method: "HEAD",
connectTimeout: 10000,
});
const duration = Date.now() - startTime;
@@ -901,15 +930,15 @@ export class ConnectionModeService {
if (response.ok) {
return {
stage: 'Stage 7: HEAD method',
stage: "Stage 7: HEAD method",
success: true,
message: 'HEAD method works (GET does not)',
message: "HEAD method works (GET does not)",
duration,
};
}
return {
stage: 'Stage 7: HEAD method',
stage: "Stage 7: HEAD method",
success: false,
message: `HEAD method returned ${response.status}`,
duration,
@@ -920,7 +949,7 @@ export class ConnectionModeService {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
stage: 'Stage 7: HEAD method',
stage: "Stage 7: HEAD method",
success: false,
message: `Failed: ${errorMsg}`,
duration,
@@ -935,10 +964,11 @@ export class ConnectionModeService {
// Try with a standard browser User-Agent instead of Tauri's default
const response = await fetch(url, {
method: 'GET',
method: "GET",
connectTimeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
},
});
const duration = Date.now() - startTime;
@@ -947,15 +977,15 @@ export class ConnectionModeService {
if (response.ok) {
return {
stage: 'Stage 8: Browser User-Agent',
stage: "Stage 8: Browser User-Agent",
success: true,
message: 'Works with browser User-Agent (blocked with desktop UA)',
message: "Works with browser User-Agent (blocked with desktop UA)",
duration,
};
}
return {
stage: 'Stage 8: Browser User-Agent',
stage: "Stage 8: Browser User-Agent",
success: false,
message: `Browser UA returned ${response.status}`,
duration,
@@ -966,7 +996,7 @@ export class ConnectionModeService {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
stage: 'Stage 8: Browser User-Agent',
stage: "Stage 8: Browser User-Agent",
success: false,
message: `Failed: ${errorMsg}`,
duration,
@@ -974,13 +1004,12 @@ export class ConnectionModeService {
}
}
async isFirstLaunch(): Promise<boolean> {
try {
const result = await invoke<boolean>('is_first_launch');
const result = await invoke<boolean>("is_first_launch");
return result;
} catch (error) {
console.error('Failed to check first launch:', error);
console.error("Failed to check first launch:", error);
return false;
}
}
@@ -990,10 +1019,10 @@ export class ConnectionModeService {
return;
}
try {
await invoke('reset_setup_completion');
console.log('Setup completion flag reset successfully');
await invoke("reset_setup_completion");
console.log("Setup completion flag reset successfully");
} catch (error) {
console.error('Failed to reset setup completion:', error);
console.error("Failed to reset setup completion:", error);
throw error;
}
}

Some files were not shown because too many files have changed in this diff Show More