Prettier 2: Electric Boogaloo (#6113)

# Description of Changes
When I added Prettier formatting in #6052, my aim was to use just the
default settings in Prettier. Turns out, Prettier looks _really hard_
for any config files if it's not explicitly given one, which means that
if a developer has some sort of Prettier config file lying around on
their system, Prettier might find it and use it. Also, Prettier changes
its defaults based on stuff in `.editorconfig` without any good way of
disabling that behaviour explicitly in its config file.

To solve both of these issues, I've introduced a `.prettierrc` file
which sets Prettier's defaults explicitly, and then reformatted all our
code _again_ in Prettier's actual default settings. This should achieve
the aim of #6052 and remove the possibility for it breaking on different
dev computers.
This commit is contained in:
James Brunton
2026-04-17 09:50:16 +00:00
committed by GitHub
parent de8c483054
commit 8ab060a4be
1207 changed files with 52646 additions and 15352 deletions
+16 -8
View File
@@ -10,17 +10,25 @@ 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
},
},
});
);
@@ -10,7 +10,10 @@ 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 {
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";
@@ -45,7 +48,9 @@ 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
@@ -92,7 +97,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
.isAuthenticated()
.then(async (isAuth) => {
if (isAuth) {
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL).catch(console.error);
await connectionModeService
.switchToSaaS(STIRLING_SAAS_URL)
.catch(console.error);
setConnectionMode("saas");
}
})
@@ -102,10 +109,14 @@ export function AppProviders({ children }: { children: ReactNode }) {
.isAuthenticated()
.then(async (isAuth) => {
if (!isAuth) {
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
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);
await connectionModeService
.switchToLocal()
.catch(console.error);
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
@@ -120,7 +131,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
}
})
.catch(async () => {
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
const cfg = await connectionModeService
.getCurrentConfig()
.catch(() => null);
if (!cfg?.lock_connection_mode) {
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode("local");
@@ -180,7 +193,10 @@ 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.
@@ -201,11 +217,18 @@ 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");
void endpointAvailabilityService.preloadEndpoints(COMMON_TOOL_ENDPOINTS, backendUrl);
console.debug(
"[AppProviders] Preloading common tool endpoints for local backend",
);
void endpointAvailabilityService.preloadEndpoints(
COMMON_TOOL_ENDPOINTS,
backendUrl,
);
};
const unsubscribe = tauriBackendService.subscribeToStatus(() => tryPreload());
const unsubscribe = tauriBackendService.subscribeToStatus(() =>
tryPreload(),
);
tryPreload();
return unsubscribe;
}, [shouldPreloadLocalEndpoints, connectionMode]);
@@ -217,7 +240,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
// pendingSignIn and therefore never reach this dispatch.
useEffect(() => {
if (!authChecked || !pendingSignIn) return;
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }));
window.dispatchEvent(
new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }),
);
setPendingSignIn(false);
}, [authChecked, pendingSignIn]);
@@ -272,7 +297,8 @@ export function AppProviders({ children }: { children: ReactNode }) {
>
<ToolActionsContext.Provider
value={{
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
onEndpointUnavailableClick: () =>
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
}}
>
<SaaSTeamProvider key={appKey}>
@@ -1,13 +1,21 @@
import React, { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Box, Tooltip, useMantineTheme, useComputedColorScheme, rem } from "@mantine/core";
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");
@@ -33,7 +41,13 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
return theme.colors.green?.[5] ?? "#37b24d";
}
return theme.colors.red?.[6] ?? "#e03131";
}, [status, isOnline, theme.colors.green, theme.colors.red, theme.colors.yellow]);
}, [
status,
isOnline,
theme.colors.green,
theme.colors.red,
theme.colors.yellow,
]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -68,7 +82,10 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
height: rem(12),
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)",
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",
@@ -1,7 +1,10 @@
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 {
connectionModeService,
ConnectionConfig,
} from "@app/services/connectionModeService";
import { authService, UserInfo } from "@app/services/authService";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
@@ -17,7 +20,10 @@ 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);
}
@@ -25,7 +31,8 @@ export const ConnectionSettings: React.FC = () => {
loadConfig();
const unsubscribe = connectionModeService.subscribeToModeChanges(loadConfig);
const unsubscribe =
connectionModeService.subscribeToModeChanges(loadConfig);
return unsubscribe;
}, []);
@@ -69,8 +76,19 @@ 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">
<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"
@@ -88,30 +106,33 @@ export const ConnectionSettings: React.FC = () => {
</Text>
)}
{(config.mode === "saas" || config.mode === "selfhosted") && config.server_config && (
<>
<div>
<Text size="sm" fw={500}>
{t("settings.connection.server", "Server")}
</Text>
<Text size="sm" c="dimmed">
{config.mode === "saas" ? "stirling.com" : config.server_config.url}
</Text>
</div>
{userInfo && (
{(config.mode === "saas" || config.mode === "selfhosted") &&
config.server_config && (
<>
<div>
<Text size="sm" fw={500}>
{t("settings.connection.user", "Logged in as")}
{t("settings.connection.server", "Server")}
</Text>
<Text size="sm" c="dimmed">
{userInfo.username}
{userInfo.email && ` (${userInfo.email})`}
{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")}
</Text>
<Text size="sm" c="dimmed">
{userInfo.username}
{userInfo.email && ` (${userInfo.email})`}
</Text>
</div>
)}
</>
)}
<Group mt="md">
{config.mode === "local" ? (
@@ -119,7 +140,12 @@ export const ConnectionSettings: React.FC = () => {
{t("settings.connection.signIn", "Sign In")}
</Button>
) : (
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
<Button
onClick={handleLogout}
color="red"
variant="light"
disabled={loading}
>
{t("settings.connection.logout", "Log Out")}
</Button>
)}
@@ -22,7 +22,9 @@ const SIGN_IN_GRADIENT: [string, string] = ["#3B82F6", "#7C3AED"];
*/
export function DesktopOnboardingModal() {
const { t } = useTranslation();
const [visible, setVisible] = useState(() => !localStorage.getItem(ONBOARDING_KEY));
const [visible, setVisible] = useState(
() => !localStorage.getItem(ONBOARDING_KEY),
);
const [step, setStep] = useState(0);
const dismissFinal = () => {
@@ -82,12 +84,21 @@ export function DesktopOnboardingModal() {
<Stack
gap={0}
className={styles.modalContent}
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
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]}
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"}
@@ -105,29 +116,50 @@ export function DesktopOnboardingModal() {
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>
<div className={styles.heroLogo} key={`logo-${step}`}>
<div className={styles.heroLogoCircle}>
{step === 0 ? (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="rocket-launch"
width={64}
height={64}
className={styles.heroIcon}
/>
) : (
<LocalIcon icon="login" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="login"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
</div>
</div>
</div>
{/* Body section */}
<div className={styles.modalBody} style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
<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} />
@@ -151,7 +183,11 @@ 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>
@@ -9,17 +9,24 @@ interface DesktopAuthLayoutProps {
children: React.ReactNode;
}
export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }) => {
export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({
children,
}) => {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
const logoVariant = useLogoVariant();
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
const imageSlides = useMemo(
() => buildLoginSlides(logoVariant, t),
[logoVariant, t],
);
// 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");
@@ -27,7 +34,10 @@ export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }
// 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,
);
}
};
}, []);
@@ -54,11 +64,20 @@ export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }
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>
{!hideRightPanel && <LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />}
{!hideRightPanel && (
<LoginRightCarousel
imageSlides={imageSlides}
initialSeconds={5}
slideSeconds={8}
/>
)}
</div>
</div>
);
@@ -6,7 +6,13 @@ 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 {
@@ -47,22 +53,38 @@ 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."),
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."),
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 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);
? await authService.loginWithOAuth(
provider.id,
serverUrl,
successHtml,
errorHtml,
)
: await authService.loginWithSelfHostedOAuth(
provider.path || provider.id,
serverUrl,
);
// Call the onOAuthSuccess callback to complete setup
await onOAuthSuccess(userInfo);
@@ -70,14 +92,22 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
console.error("OAuth login failed:", error);
const errorMessage =
error instanceof Error ? error.message : t("setup.login.error.oauthFailed", "OAuth login failed. Please try again.");
error instanceof Error
? error.message
: t(
"setup.login.error.oauthFailed",
"OAuth login failed. Please try again.",
);
onError(errorMessage);
setOauthLoading(false);
}
};
const providerConfig: Record<KnownProviderId, { label: string; file: string }> = {
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" },
@@ -85,14 +115,17 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
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 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);
if (providers.length === 0) {
console.warn("[DesktopOAuthButtons] No providers to display, returning null");
console.warn(
"[DesktopOAuthButtons] No providers to display, returning null",
);
return null;
}
@@ -100,14 +133,20 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
return (
<div className="oauth-container-vertical-desktop">
{providers
.filter((providerConfigEntry) => providerConfigEntry && providerConfigEntry.id)
.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)
? providerEntry.id.charAt(0).toUpperCase() +
providerEntry.id.slice(1)
: t("setup.login.sso", "Single Sign-On"));
return (
<button
@@ -131,8 +170,18 @@ 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>
@@ -40,12 +40,16 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
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 +65,10 @@ 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} />
@@ -96,7 +103,10 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
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={() => {
@@ -113,8 +123,16 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
<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}>
<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>
@@ -3,7 +3,10 @@ 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 {
useSignupFormValidation,
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { authService } from "@app/services/authService";
import "@app/routes/authShared/auth.css";
@@ -25,8 +28,12 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
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);
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>(
{},
);
const [signupSuccessMessage, setSignupSuccessMessage] = useState<
string | null
>(null);
const [isSignupSubmitting, setIsSignupSubmitting] = useState(false);
const { validateSignupForm } = useSignupFormValidation();
@@ -48,13 +55,19 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
setIsSignupSubmitting(true);
await authService.signUpSaas(email.trim(), password);
setSignupSuccessMessage(
t("signup.checkEmailConfirmation", "Check your email for a confirmation link to complete your registration."),
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);
@@ -63,7 +76,10 @@ 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 && (
@@ -7,12 +7,20 @@ interface SelfHostedLinkProps {
disabled?: boolean;
}
export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({ onClick, disabled = false }) => {
export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({
onClick,
disabled = false,
}) => {
const { t } = useTranslation();
return (
<div className="navigation-link-container" style={{ marginTop: "1.5rem" }}>
<button type="button" onClick={onClick} disabled={disabled} className="navigation-link-button">
<button
type="button"
onClick={onClick}
disabled={disabled}
className="navigation-link-button"
>
{t("setup.selfhosted.link", "or connect to a self hosted account")}
</button>
</div>
@@ -48,18 +48,24 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
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;
}
@@ -82,7 +88,11 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
<>
<LoginHeader
title={t("setup.selfhosted.title", "Sign in to Server")}
subtitle={isUserPassAllowed ? t("setup.selfhosted.subtitle", "Enter your server credentials") : undefined}
subtitle={
isUserPassAllowed
? t("setup.selfhosted.subtitle", "Enter your server credentials")
: undefined
}
/>
<ErrorMessage error={displayError} />
@@ -1,7 +1,10 @@
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 {
ServerConfig,
SSOProviderConfig,
} from "@app/services/connectionModeService";
import { connectionModeService } from "@app/services/connectionModeService";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -10,7 +13,10 @@ interface ServerSelectionProps {
loading: boolean;
}
export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, loading }) => {
export const ServerSelection: React.FC<ServerSelectionProps> = ({
onSelect,
loading,
}) => {
const { t } = useTranslation();
const [customUrl, setCustomUrl] = useState("");
const [testing, setTesting] = useState(false);
@@ -25,7 +31,9 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
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;
}
@@ -47,7 +55,10 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
} 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"),
t(
"setup.server.error.invalidUrl",
"Invalid URL format. Please enter a valid URL like https://your-server.com",
),
);
return;
}
@@ -64,7 +75,10 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
if (!testResult.success) {
console.error("[ServerSelection] Connection test failed:", testResult);
setTestError(testResult.error || t("setup.server.error.unreachable", "Could not connect to server"));
setTestError(
testResult.error ||
t("setup.server.error.unreachable", "Could not connect to server"),
);
setTesting(false);
return;
}
@@ -80,19 +94,31 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Check if security is disabled (status 403, 401, or 404 - endpoint doesn't exist)
if (!response.ok) {
console.warn(`[ServerSelection] Login config request failed with status ${response.status}`);
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)");
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)",
);
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;
@@ -103,7 +129,9 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// 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;
@@ -116,12 +144,23 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// 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);
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;
}
@@ -132,27 +171,46 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
});
});
console.log("[ServerSelection] ✅ Detected OAuth providers:", enabledProviders);
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;
@@ -160,15 +218,25 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Connection successful — persist URL so it pre-fills on next sign-in
localStorage.setItem("server_url", url);
console.log("[ServerSelection] ✅ Server selection complete, proceeding to login");
console.log(
"[ServerSelection] ✅ Server selection complete, proceeding to login",
);
onSelect({
url,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
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);
}
@@ -188,15 +256,27 @@ 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 && (
<Alert
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")}
icon={
<LocalIcon
icon="warning-rounded"
width="1.25rem"
height="1.25rem"
/>
}
title={t(
"setup.server.error.securityDisabled.title",
"Login Not Enabled",
)}
>
<Stack gap="sm">
<Text size="sm">
@@ -208,10 +288,23 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
<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")}
{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>
<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>
@@ -232,13 +325,24 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
}, 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>
@@ -12,14 +12,21 @@ 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")}
subtitle={t(
"setup.server.subtitle",
"Enter your self-hosted server URL",
)}
/>
<ErrorMessage error={error} />
@@ -6,8 +6,16 @@ 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 {
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";
@@ -29,10 +37,16 @@ interface SetupWizardProps {
onClose?: () => void;
}
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout = false, onClose }) => {
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 [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("");
@@ -84,7 +98,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
onComplete();
} catch (err) {
console.error("SaaS OAuth login completion failed:", err);
setError(err instanceof Error ? err.message : "Failed to complete SaaS login");
setError(
err instanceof Error ? err.message : "Failed to complete SaaS login",
);
setLoading(false);
}
};
@@ -183,7 +199,10 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
} 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);
@@ -218,8 +237,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
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] ❌ 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);
@@ -250,9 +273,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
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;
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;
}
@@ -266,7 +292,10 @@ 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;
}
@@ -278,13 +307,20 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setLoading(true);
setError(null);
await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL);
await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL);
await authService.completeSupabaseSession(
accessToken,
serverConfig?.url || STIRLING_SAAS_URL,
);
await connectionModeService.switchToSaaS(
serverConfig?.url || STIRLING_SAAS_URL,
);
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");
setError(
err instanceof Error ? err.message : "Failed to complete signup",
);
setLoading(false);
}
});
@@ -322,10 +358,14 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
setLockedServerChecking(true);
const savedUrl = serverUrl.replace(/\/+$/, "");
let updatedConfig: ServerConfig = { ...(currentConfig.server_config ?? { url: savedUrl }) };
let updatedConfig: ServerConfig = {
...(currentConfig.server_config ?? { url: savedUrl }),
};
try {
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
const response = await fetch(
`${savedUrl}/api/v1/proprietary/ui-data/login`,
);
if (response.ok) {
const data = await response.json();
@@ -345,7 +385,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
updatedConfig = {
...updatedConfig,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
enabledOAuthProviders:
enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod: data.loginMethod || "all",
};
@@ -391,11 +432,20 @@ 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 && (
@@ -404,77 +454,122 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
</Center>
)}
{activeStep === SetupStep.SelfHostedLogin && lockedServerUnreachable && !lockedServerChecking && (
<Stack gap="md" style={{ padding: "0.5rem 0" }}>
<Alert color="orange" title={t("setup.selfhosted.unreachable.title", "Cannot connect to server")}>
<Text size="sm">
{t(
"setup.selfhosted.unreachable.message",
"Could not reach {{url}}. Check that the server is running and accessible.",
{
url: serverConfig?.url,
},
)}
</Text>
</Alert>
<Button variant="filled" color="blue" fullWidth loading={loading} onClick={() => void loadLockedConfig()}>
{t("setup.selfhosted.unreachable.retry", "Retry")}
</Button>
{lockConnectionMode ? (
<DisabledButtonWithTooltip
tooltip={t(
"setup.selfhosted.changeServerLocked",
"Your organisation has restricted this app to a specific server",
{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",
)}
>
{t("setup.selfhosted.unreachable.changeServer", "Connect to a different server")}
</DisabledButtonWithTooltip>
) : (
<Text size="sm">
{t(
"setup.selfhosted.unreachable.message",
"Could not reach {{url}}. Check that the server is running and accessible.",
{
url: serverConfig?.url,
},
)}
</Text>
</Alert>
<Button
variant="light"
variant="filled"
color="blue"
fullWidth
loading={loading}
onClick={() => {
setLockedServerUnreachable(false);
setActiveStep(SetupStep.ServerSelection);
}}
onClick={() => void loadLockedConfig()}
>
{t("setup.selfhosted.unreachable.changeServer", "Connect to a different server")}
{t("setup.selfhosted.unreachable.retry", "Retry")}
</Button>
)}
<Button variant="subtle" color="white" fullWidth onClick={handleLocalMode}>
{t("setup.selfhosted.unreachable.continueOffline", "Use local tools instead")}
</Button>
</Stack>
)}
{lockConnectionMode ? (
<DisabledButtonWithTooltip
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",
)}
</DisabledButtonWithTooltip>
) : (
<Button
variant="light"
color="blue"
fullWidth
loading={loading}
onClick={() => {
setLockedServerUnreachable(false);
setActiveStep(SetupStep.ServerSelection);
}}
>
{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>
</Stack>
)}
{activeStep === SetupStep.SelfHostedLogin && !lockedServerUnreachable && !lockedServerChecking && (
<>
<SelfHostedLoginScreen
serverUrl={serverConfig?.url || ""}
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
loginMethod={serverConfig?.loginMethod}
onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess}
mfaCode={selfHostedMfaCode}
setMfaCode={setSelfHostedMfaCode}
requiresMfa={selfHostedMfaRequired}
loading={loading}
error={error}
/>
<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>
</>
)}
{activeStep === SetupStep.SelfHostedLogin &&
!lockedServerUnreachable &&
!lockedServerChecking && (
<>
<SelfHostedLoginScreen
serverUrl={serverConfig?.url || ""}
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
loginMethod={serverConfig?.loginMethod}
onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess}
mfaCode={selfHostedMfaCode}
setMfaCode={setSelfHostedMfaCode}
requiresMfa={selfHostedMfaRequired}
loading={loading}
error={error}
/>
<div
className="navigation-link-container"
style={{ marginTop: "1.5rem" }}
>
<button
type="button"
onClick={handleLocalMode}
className="navigation-link-button"
disabled={loading}
>
{t("setup.selfhosted.switchToLocal", "Use local tools instead")}
</button>
</div>
</>
)}
{/* Back Button */}
{!lockConnectionMode && activeStep > SetupStep.SaaSLogin && !loading && (
<div className="navigation-link-container" style={{ marginTop: "1.5rem" }}>
<button type="button" onClick={handleBack} className="navigation-link-button">
<div
className="navigation-link-container"
style={{ marginTop: "1.5rem" }}
>
<button
type="button"
onClick={handleBack}
className="navigation-link-button"
>
{t("common.back", "Back")}
</button>
</div>
@@ -9,7 +9,11 @@
*/
// 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,
@@ -18,5 +18,7 @@ export type {
} from "@core/components/onboarding/orchestrator/useOnboardingOrchestrator";
export function useOnboardingOrchestrator(): UseOnboardingOrchestratorResult {
return useCoreOnboardingOrchestrator({ defaultRuntimeState: DEFAULT_RUNTIME_STATE });
return useCoreOnboardingOrchestrator({
defaultRuntimeState: DEFAULT_RUNTIME_STATE,
});
}
@@ -15,7 +15,9 @@ interface QuickAccessBarFooterExtensionsProps {
className?: string;
}
export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFooterExtensionsProps) {
export function QuickAccessBarFooterExtensions({
className,
}: QuickAccessBarFooterExtensionsProps) {
const { creditBalance, loading, isManagedTeamMember } = useSaaSBilling();
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -70,7 +72,11 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
};
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"}
@@ -1,8 +1,14 @@
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 {
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";
@@ -13,15 +19,21 @@ interface RightRailFooterExtensionsProps {
function ConnectionStatusDot() {
const { t } = useTranslation();
const colorScheme = useComputedColorScheme("light");
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(() => selfHostedServerMonitor.getSnapshot());
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
null,
);
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(
() => selfHostedServerMonitor.getSnapshot(),
);
const { isOnline, checkHealth } = useBackendHealth();
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
const unsubscribe = connectionModeService.subscribeToModeChanges((config) => {
setConnectionMode(config.mode);
});
const unsubscribe = connectionModeService.subscribeToModeChanges(
(config) => {
setConnectionMode(config.mode);
},
);
return unsubscribe;
}, []);
@@ -40,13 +52,26 @@ function ConnectionStatusDot() {
const serverOnline = selfHostedState.isOnline;
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
@@ -84,7 +109,10 @@ function ConnectionStatusDot() {
height: rem(10),
borderRadius: "50%",
backgroundColor: color,
boxShadow: colorScheme === "dark" ? "0 0 0 2px rgba(255, 255, 255, 0.15)" : "0 0 0 2px rgba(0, 0, 0, 0.07)",
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",
@@ -94,7 +122,9 @@ function ConnectionStatusDot() {
);
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
export function RightRailFooterExtensions({
className,
}: RightRailFooterExtensionsProps) {
return (
<Box
className={className}
@@ -15,7 +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 }} />}
@@ -15,7 +15,10 @@ 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.")}
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}
@@ -14,11 +14,23 @@ interface DisabledButtonWithTooltipProps {
* Mantine's disabled prop prevents pointer events entirely, so this is a plain
* div styled to match a disabled button with a custom hover tooltip.
*/
export function DisabledButtonWithTooltip({ tooltip, children, className, style }: 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,14 +1,32 @@
import { useState, useEffect, useMemo } from "react";
import { Paper, Group, Text, ActionIcon, UnstyledButton, Popover, List, ScrollArea } from "@mantine/core";
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 {
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 {
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";
@@ -39,16 +57,24 @@ 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 [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
null,
);
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
@@ -75,7 +101,9 @@ export function SelfHostedOfflineBanner() {
// Re-use conversion availability already computed by useConversionCloudStatus.
const { availability: conversionAvailability } = useConversionCloudStatus();
const [splitAvailability, setSplitAvailability] = useState<Record<string, boolean>>({});
const [splitAvailability, setSplitAvailability] = useState<
Record<string, boolean>
>({});
useEffect(() => {
if (serverState.status !== "offline") {
setSplitAvailability({});
@@ -86,11 +114,15 @@ export function SelfHostedOfflineBanner() {
setSplitAvailability({});
return;
}
const uniqueEndpoints = [...new Set(Object.values(SPLIT_ENDPOINTS))] as string[];
const uniqueEndpoints = [
...new Set(Object.values(SPLIT_ENDPOINTS)),
] as string[];
void Promise.all(
uniqueEndpoints.map(async (ep) => ({
ep,
supported: await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl).catch(() => false),
supported: await endpointAvailabilityService
.isEndpointSupportedLocally(ep, localUrl)
.catch(() => false),
})),
).then((results) => {
const map: Record<string, boolean> = {};
@@ -102,7 +134,11 @@ 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")
.filter(
(id) =>
toolAvailability[id]?.available === false &&
toolAvailability[id]?.reason === "selfHostedOffline",
)
.map((id) => toolRegistry[id]?.name ?? id)
.filter(Boolean) as string[];
@@ -140,16 +176,31 @@ export function SelfHostedOfflineBanner() {
.filter(Boolean);
return [...toolNames, ...conversionNames, ...unavailableSplitNames].sort();
}, [toolAvailability, toolRegistry, conversionAvailability, splitAvailability, t]);
}, [
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
@@ -159,15 +210,42 @@ export function SelfHostedOfflineBanner() {
borderBottom: `1px solid ${BANNER_BORDER}`,
}}
>
<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 }} />
<Text size="xs" fw={600} style={{ color: BANNER_TEXT, flexShrink: 0 }}>
<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 }}
/>
<Text
size="xs"
fw={600}
style={{ color: BANNER_TEXT, flexShrink: 0 }}
>
{t("selfHosted.offline.title", "Server unreachable")}
</Text>
<Text
size="xs"
style={{ color: BANNER_TEXT, opacity: 0.8, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
style={{
color: BANNER_TEXT,
opacity: 0.8,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{messageText}
</Text>
@@ -193,8 +271,14 @@ export function SelfHostedOfflineBanner() {
}}
>
{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">
@@ -9,7 +9,8 @@ import { connectionModeService } from "@app/services/connectionModeService";
export function TeamInvitationBanner() {
const { t } = useTranslation();
const { receivedInvitations, acceptInvitation, rejectInvitation } = useSaaSTeam();
const { receivedInvitations, acceptInvitation, rejectInvitation } =
useSaaSTeam();
const { refreshBilling } = useSaaSBilling();
const [processing, setProcessing] = useState(false);
@@ -18,7 +19,9 @@ 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 +33,26 @@ 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));
// 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);
}
@@ -59,22 +70,32 @@ export function TeamInvitationBanner() {
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>
);
@@ -88,7 +109,12 @@ export function TeamInvitationBanner() {
onClick={handleAccept}
loading={processing}
leftSection={
<LocalIcon icon="check" width="0.9rem" height="0.9rem" style={{ color: "var(--mantine-color-dark-9)" }} />
<LocalIcon
icon="check"
width="0.9rem"
height="0.9rem"
style={{ color: "var(--mantine-color-dark-9)" }}
/>
}
styles={{
label: {
@@ -114,7 +140,12 @@ 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>
@@ -18,7 +18,12 @@ 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" });
@@ -35,14 +40,20 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({ opened,
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,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
const errorMessage =
err instanceof Error
? err.message
: "Failed to create checkout session";
console.error("[SaaSStripeCheckout] Error creating checkout:", err);
setState({
status: "error",
@@ -77,10 +88,16 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({ opened,
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) {
@@ -103,7 +120,11 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({ 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(
@@ -111,8 +132,16 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({ opened,
"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")}
@@ -151,7 +180,9 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({ opened,
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>
}
@@ -11,7 +11,10 @@ import { SaaSTeamsSection } from "@app/components/shared/config/configSections/S
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
@@ -28,7 +31,9 @@ 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
@@ -43,7 +48,11 @@ export const useConfigNavSections = (
const isLocalMode = connectionMode === "local";
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
const sections = useProprietaryConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
);
const connectionModeSection: ConfigNavSection = {
title: t("settings.connection.title", "Connection Mode"),
@@ -115,10 +124,16 @@ export const useConfigNavSections = (
// and hiding the Account section when not authenticated.
for (const section of sections.slice(1)) {
const firstItemKey = section.items[0]?.key;
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
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 });
}
@@ -135,10 +150,16 @@ export const createConfigNavSections = (
runningEE: 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);
const sections = createProprietaryConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
);
// Add Connection section at the beginning (after Preferences)
sections.splice(1, 0, {
@@ -16,9 +16,15 @@ export const DefaultAppSettings: React.FC = () => {
</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.defaultPdfEditorInactive",
"Another application is set as default",
)
: t("settings.general.defaultPdfEditorChecking", "Checking...")}
</Text>
</div>
@@ -72,7 +72,9 @@ 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) => {
@@ -85,37 +87,71 @@ export function SaaSTeamsSection() {
try {
await inviteUser(inviteEmail);
setSuccess(t("team.inviteSent", "Invitation sent to {{email}}", { email: inviteEmail }));
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"));
} 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;
const handleCancelInvitation = async (
invitationId: number,
email: string,
) => {
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"),
);
}
};
@@ -146,8 +182,15 @@ export function SaaSTeamsSection() {
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"));
const error = err as {
response?: { data?: { error?: string } };
message?: string;
};
setError(
error.response?.data?.error ||
error.message ||
t("team.renameError", "Failed to rename team"),
);
} finally {
setRenamingTeam(false);
}
@@ -162,7 +205,9 @@ export function SaaSTeamsSection() {
'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.confirmLeave", 'Are you sure you want to leave "{{name}}"?', {
name: currentTeam.name,
});
if (!window.confirm(confirmMessage)) return;
@@ -170,8 +215,15 @@ export function SaaSTeamsSection() {
await leaveTeam();
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"));
const error = err as {
response?: { data?: { error?: string } };
message?: string;
};
setError(
error.response?.data?.error ||
error.message ||
t("team.leaveError", "Failed to leave team"),
);
}
};
@@ -211,7 +263,12 @@ 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>
@@ -230,7 +287,9 @@ export function SaaSTeamsSection() {
<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")}
@@ -240,7 +299,9 @@ export function SaaSTeamsSection() {
)}
{!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>
@@ -250,7 +311,9 @@ export function SaaSTeamsSection() {
variant="outline"
size="xs"
onClick={handleLeaveTeam}
leftSection={<LocalIcon icon="logout" width="1rem" height="1rem" />}
leftSection={
<LocalIcon icon="logout" width="1rem" height="1rem" />
}
>
{t("team.leaveButton", "Leave Team")}
</Button>
@@ -260,15 +323,28 @@ export function SaaSTeamsSection() {
{/* Upgrade Banner for Free Users */}
{isPersonalTeam && !isPro && (
<Alert color="blue" icon={<LocalIcon icon="info" width={16} height={16} />}>
<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")}
{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(
"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>
@@ -311,7 +387,10 @@ export function SaaSTeamsSection() {
{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>
@@ -326,27 +405,53 @@ export function SaaSTeamsSection() {
}
>
<List.Item>
<Text fw={500}>{t("team.features.invite.title", "Invite team members")}</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")}
{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 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")}
{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 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")}
{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 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")}
{t(
"team.features.billing.description",
"One invoice for all team seats and usage",
)}
</Text>
</List.Item>
</List>
@@ -403,7 +508,10 @@ export function SaaSTeamsSection() {
<Button
type="submit"
loading={inviting}
disabled={!inviteEmail.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)}
disabled={
!inviteEmail.trim() ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
}
>
{t("team.invite.sendButton", "Send Invite")}
</Button>
@@ -429,17 +537,39 @@ export function SaaSTeamsSection() {
}
>
<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)" }}>
<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)" }}>
<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)" }}>
<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>
@@ -485,17 +615,33 @@ export function SaaSTeamsSection() {
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
{member.role !== "LEADER" && (
<Menu position="bottom-end" withinPortal zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Menu
position="bottom-end"
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Menu.Target>
<ActionIcon variant="subtle">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
onClick={() => handleRemove(member.id, member.email)}
leftSection={
<LocalIcon
icon="person-remove"
width="1rem"
height="1rem"
/>
}
onClick={() =>
handleRemove(member.id, member.email)
}
>
{t("team.members.remove", "Remove from Team")}
</Menu.Item>
@@ -532,10 +678,22 @@ export function SaaSTeamsSection() {
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleCancelInvitation(invitation.invitationId, invitation.inviteeEmail)}
aria-label={t("team.invite.cancelLabel", "Cancel invitation")}
onClick={() =>
handleCancelInvitation(
invitation.invitationId,
invitation.inviteeEmail,
)
}
aria-label={t(
"team.invite.cancelLabel",
"Cancel invitation",
)}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
<LocalIcon
icon="close"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Table.Td>
)}
@@ -1,5 +1,13 @@
import { useEffect, useState } from "react";
import { Stack, Loader, Alert, Button, Center, Text, Flex } from "@mantine/core";
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";
@@ -54,9 +62,11 @@ export function SaasPlanSection() {
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(async (config) => {
setIsSaasMode(config.mode === "saas");
});
const unsubscribe = connectionModeService.subscribeToModeChanges(
async (config) => {
setIsSaasMode(config.mode === "saas");
},
);
return unsubscribe;
}, []);
@@ -88,7 +98,11 @@ export function SaasPlanSection() {
if (isSaasMode === false) {
return (
<Center p="xl">
<Alert color="blue" variant="light" icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}>
<Alert
color="blue"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
>
<Text size="sm">
{t(
"settings.planBilling.notAvailable",
@@ -117,7 +131,10 @@ 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,11 +149,19 @@ 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">
<Button
variant="light"
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
onClick={refreshBilling}
size="xs"
>
{t("settings.planBilling.errors.retry", "Retry")}
</Button>
</Stack>
@@ -151,7 +176,13 @@ 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" }}>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("settings.planBilling.currentPlan", "Active Plan")}
</h3>
{tier !== "free" && !isManagedTeamMember && (
@@ -162,34 +193,43 @@ export function SaasPlanSection() {
loading={isOpeningPortal}
disabled={isOpeningPortal}
>
{t("settings.planBilling.billing.manageBilling", "Manage Billing")}
{t(
"settings.planBilling.billing.manageBilling",
"Manage Billing",
)}
</Button>
)}
</Flex>
{/* Trial Status Alert */}
{isTrialing && trialDaysRemaining !== undefined && subscription?.currentPeriodEnd && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t("settings.planBilling.trial.title", "Free Trial Active")}
>
<Text size="sm">
{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}}", {
date: formatDate(subscription.currentPeriodEnd),
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
})}
</Text>
</Alert>
)}
{isTrialing &&
trialDaysRemaining !== undefined &&
subscription?.currentPeriodEnd && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t("settings.planBilling.trial.title", "Free Trial Active")}
>
<Text size="sm">
{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}}", {
date: formatDate(subscription.currentPeriodEnd),
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
})}
</Text>
</Alert>
)}
{/* Plan cards */}
<Stack gap="lg">
@@ -207,7 +247,12 @@ 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,4 +1,12 @@
import { Card, Text, Group, Badge, Stack, Tooltip, ActionIcon } from "@mantine/core";
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";
@@ -78,7 +86,10 @@ export function ActiveSubscriptionCard({
// Get description
const getDescription = (): string => {
if (tier === "free") {
return t("settings.planBilling.tier.freeDescription", "50 credits per month");
return t(
"settings.planBilling.tier.freeDescription",
"50 credits per month",
);
}
return t(
"settings.planBilling.tier.teamDescription",
@@ -105,10 +116,16 @@ 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 }} />}>
<Badge
color="violet"
variant="light"
leftSection={<GroupIcon sx={{ fontSize: 12 }} />}
>
{t("settings.planBilling.tier.teamBadge", "Team")}
</Badge>
)}
@@ -152,12 +169,19 @@ export function ActiveSubscriptionCard({
</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">
@@ -166,7 +190,10 @@ export function ActiveSubscriptionCard({
{/* Show overage cost if applicable */}
{usage && usage.currentPeriodCredits > 0 && (
<Text size="sm" c="orange" fw={500}>
{formatOverageCost(usage.estimatedCost, usage.currentPeriodCredits)}
{formatOverageCost(
usage.estimatedCost,
usage.currentPeriodCredits,
)}
</Text>
)}
</div>
@@ -175,7 +202,10 @@ export function ActiveSubscriptionCard({
<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}>
@@ -189,7 +219,10 @@ export function ActiveSubscriptionCard({
{subscription?.currentPeriodEnd && (
<Group gap="xs" mt="xs">
<Text size="sm" c="dimmed">
{t("settings.planBilling.billing.nextBillingDate", "Next billing date:")}{" "}
{t(
"settings.planBilling.billing.nextBillingDate",
"Next billing date:",
)}{" "}
{formatDate(subscription.currentPeriodEnd)}
</Text>
</Group>
@@ -59,10 +59,30 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
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 */}
@@ -71,7 +91,10 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
</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>
@@ -13,12 +13,9 @@ interface SaaSAvailablePlansSectionProps {
error?: string | null;
}
export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps> = ({
plans,
currentTier,
loading,
error,
}) => {
export const SaaSAvailablePlansSection: React.FC<
SaaSAvailablePlansSectionProps
> = ({ plans, currentTier, loading, error }) => {
const { t } = useTranslation();
const { openCheckout } = useSaaSCheckout();
@@ -33,7 +30,10 @@ 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,7 +48,12 @@ 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>
);
}
@@ -12,11 +12,18 @@ 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 = () => {
@@ -108,7 +115,9 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({ plan, isCurrentPlan,
</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">
@@ -132,14 +141,24 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({ plan, isCurrentPlan,
{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:")}
? 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}
@@ -150,7 +169,13 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({ plan, isCurrentPlan,
<div className="flex-grow" />
<Button
variant={isCurrentPlan || isIncluded ? "subtle" : plan.isContactOnly ? "outline" : "filled"}
variant={
isCurrentPlan || isIncluded
? "subtle"
: plan.isContactOnly
? "outline"
: "filled"
}
color={plan.isContactOnly ? undefined : "blue"}
disabled={isCurrentPlan || isIncluded}
fullWidth
@@ -173,7 +198,11 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({ plan, isCurrentPlan,
}),
}}
component={plan.isContactOnly ? "a" : undefined}
href={plan.isContactOnly ? `mailto:[email protected]?subject=${plan.name} Plan Inquiry` : undefined}
href={
plan.isContactOnly
? `mailto:[email protected]?subject=${plan.name} Plan Inquiry`
: undefined
}
>
{isCurrentPlan
? t("plan.current", "Current Plan")
@@ -72,10 +72,21 @@ 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 }} />}>
<Alert
color="blue"
variant="light"
icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}
>
<Text size="xs">
{t("settings.planBilling.credits.overageInfo", {
price: getFormattedOveragePrice(),
@@ -1,5 +1,9 @@
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];
@@ -1,13 +1,30 @@
import { Modal, Stack, Card, Text, Group, Badge, Button, Alert } from "@mantine/core";
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 {
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 {
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";
@@ -21,17 +38,18 @@ interface CreditExhaustedModalProps {
* Shows upgrade options when user runs out of credits
* Routes to different UI based on user status (free/team/managed member)
*/
export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalProps) {
export function CreditExhaustedModal({
opened,
onClose,
}: CreditExhaustedModalProps) {
const { t } = useTranslation();
const { creditBalance, tier, plans, refreshBilling, isManagedTeamMember } = useSaaSBilling();
const { creditBalance, tier, plans, refreshBilling, isManagedTeamMember } =
useSaaSBilling();
const { isTeamLeader } = useSaaSTeam();
const { openCheckout } = useSaaSCheckout();
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
refreshBilling,
onClose,
"CreditExhaustedModal",
);
const { enablingMetering, meteringError, handleEnableMetering } =
useEnableMeteredBilling(refreshBilling, onClose, "CreditExhaustedModal");
// Managed team members have unlimited credits via team
if (isManagedTeamMember) {
@@ -65,8 +83,12 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
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);
const overagePrice =
teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const formattedOveragePrice = getFormattedOveragePrice(
teamCurrency,
overagePrice,
);
return (
<Modal
@@ -82,10 +104,16 @@ 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>
}
@@ -103,7 +131,10 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
}}
>
<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">
@@ -126,9 +157,14 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
>
<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>
@@ -147,18 +183,31 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
})}
</FeatureListItem>
<FeatureListItem included>
{t("credits.modal.meteringPrice", "Additional credits at {{price}}/credit", {
price: formattedOveragePrice,
})}
{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>
@@ -199,10 +248,20 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
</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}>
<Button
onClick={onClose}
variant="subtle"
fullWidth
size="md"
c="dimmed"
disabled={enablingMetering}
>
{t("credits.maybeLater", "Maybe later")}
</Button>
</Stack>
@@ -215,7 +274,8 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
const teamPlan = plans.get("team");
const teamPrice = teamPlan?.price ?? 20;
const teamCurrency = teamPlan?.currency ?? "$";
const overagePrice = teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const overagePrice =
teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const currencySymbol = getCurrencySymbol(teamCurrency);
const formattedOveragePrice = `${currencySymbol}${overagePrice.toFixed(2)}`;
@@ -237,7 +297,10 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
{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>
}
@@ -255,7 +318,10 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
}}
>
<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 */}
@@ -284,7 +350,8 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
</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>
@@ -293,7 +360,11 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
{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)">
<FeatureListItem
key={index}
included
color="var(--mantine-color-gray-6)"
>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
@@ -335,11 +406,13 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
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.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.boxShadow =
"0 2px 8px rgba(59, 130, 246, 0.1)";
}}
>
<Badge
@@ -366,7 +439,11 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
{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>
@@ -375,14 +452,19 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
</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>
@@ -432,7 +514,10 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
<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>
@@ -468,7 +553,10 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
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";
}}
@@ -21,7 +21,13 @@ export function CreditModalBootstrap() {
}>({});
const isSaaSMode = useSaaSMode();
const { creditBalance, isManagedTeamMember, lastFetchTime, plansLastFetchTime, refreshPlans } = useSaaSBilling();
const {
creditBalance,
isManagedTeamMember,
lastFetchTime,
plansLastFetchTime,
refreshPlans,
} = useSaaSBilling();
// Preload plan pricing when billing confirms credits are low.
// Fires once: only when in SaaS mode, billing has loaded (lastFetchTime set) and plans haven't been
@@ -36,7 +42,14 @@ export function CreditModalBootstrap() {
) {
refreshPlans();
}
}, [isSaaSMode, lastFetchTime, plansLastFetchTime, creditBalance, isManagedTeamMember, refreshPlans]);
}, [
isSaaSMode,
lastFetchTime,
plansLastFetchTime,
creditBalance,
isManagedTeamMember,
refreshPlans,
]);
// Monitor credit balance and dispatch events
useCreditEvents();
@@ -70,13 +83,19 @@ export function CreditModalBootstrap() {
return () => {
window.removeEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
window.removeEventListener(CREDIT_EVENTS.INSUFFICIENT, handleInsufficient);
window.removeEventListener(
CREDIT_EVENTS.INSUFFICIENT,
handleInsufficient,
);
};
}, [isManagedTeamMember, creditBalance]);
return (
<>
<CreditExhaustedModal opened={exhaustedOpen && !insufficientOpen} onClose={() => setExhaustedOpen(false)} />
<CreditExhaustedModal
opened={exhaustedOpen && !insufficientOpen}
onClose={() => setExhaustedOpen(false)}
/>
<InsufficientCreditsModal
opened={insufficientOpen}
onClose={() => setInsufficientOpen(false)}
@@ -10,9 +10,13 @@ interface CreditUsageBannerProps {
* Credit usage banner showing remaining credits with progress bar
* Used in credit exhausted and upgrade modals
*/
export function CreditUsageBanner({ currentCredits, totalCredits }: CreditUsageBannerProps) {
export function CreditUsageBanner({
currentCredits,
totalCredits,
}: CreditUsageBannerProps) {
const { t } = useTranslation();
const percentageRemaining = totalCredits > 0 ? (currentCredits / totalCredits) * 100 : 0;
const percentageRemaining =
totalCredits > 0 ? (currentCredits / totalCredits) * 100 : 0;
return (
<Stack gap="md">
@@ -23,10 +27,14 @@ export function CreditUsageBanner({ currentCredits, totalCredits }: CreditUsageB
{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", {
current: currentCredits,
total: totalCredits,
})}
{t(
"credits.modal.creditsRemaining",
"{{current}} of {{total}} remaining",
{
current: currentCredits,
total: totalCredits,
},
)}
</Text>
</Group>
<Progress
@@ -38,8 +38,20 @@ 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>
@@ -18,19 +18,28 @@ 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 { creditBalance, tier, refreshBilling, isManagedTeamMember } =
useSaaSBilling();
const { isTeamLeader } = useSaaSTeam();
const { openCheckout } = useSaaSCheckout();
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
refreshBilling,
onClose,
"InsufficientCreditsModal",
);
const { enablingMetering, meteringError, handleEnableMetering } =
useEnableMeteredBilling(
refreshBilling,
onClose,
"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
@@ -42,7 +51,9 @@ export function InsufficientCreditsModal({ opened, onClose, toolId, requiredCred
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")}
</Text>
@@ -76,7 +87,10 @@ export function InsufficientCreditsModal({ opened, onClose, toolId, requiredCred
{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")}
@@ -85,7 +99,10 @@ export function InsufficientCreditsModal({ opened, onClose, toolId, requiredCred
) : 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>}
<Button
@@ -100,17 +117,28 @@ export function InsufficientCreditsModal({ opened, onClose, toolId, requiredCred
</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}>
<Button
onClick={onClose}
variant="subtle"
fullWidth
disabled={enablingMetering}
>
{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"
@@ -4,7 +4,10 @@ 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 {
connectionModeService,
type ConnectionMode,
} from "@app/services/connectionModeService";
import type { ToolId } from "@app/types/toolId";
type CoreToolButtonProps = React.ComponentProps<typeof CoreToolButton>;
@@ -20,11 +23,15 @@ const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
const { toolAvailability, handleToolSelectForced } = useToolWorkflow();
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
null,
);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges((cfg) => setConnectionMode(cfg.mode));
return connectionModeService.subscribeToModeChanges((cfg) =>
setConnectionMode(cfg.mode),
);
}, []);
const disabledReason = getToolDisabledReason(
@@ -39,11 +46,15 @@ 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;
return <CoreToolButton {...props} onUnavailableClick={handleUnavailableClick} />;
return (
<CoreToolButton {...props} onUnavailableClick={handleUnavailableClick} />
);
};
export default ToolButton;
@@ -1,7 +1,10 @@
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 {
connectionModeService,
type ConnectionMode,
} from "@app/services/connectionModeService";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
/**
@@ -11,13 +14,17 @@ import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
*/
export function ToolPickerFooterExtensions() {
const { t } = useTranslation();
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
null,
);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
const unsubscribe = connectionModeService.subscribeToModeChanges((config) => {
setConnectionMode(config.mode);
});
const unsubscribe = connectionModeService.subscribeToModeChanges(
(config) => {
setConnectionMode(config.mode);
},
);
return unsubscribe;
}, []);
@@ -45,7 +52,9 @@ export function ToolPickerFooterExtensions() {
variant="light"
color="blue"
style={{ flexShrink: 0 }}
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))}
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))
}
>
{t("localMode.toolPicker.signIn", "Sign In")}
</Button>
+10 -4
View File
@@ -49,9 +49,14 @@ 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 {
export function getFormattedOveragePrice(
currency: string = "usd",
price?: number,
): string {
const symbol =
BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || "$";
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)}`;
}
@@ -61,7 +66,8 @@ export function getFormattedOveragePrice(currency: string = "usd", price?: numbe
*/
export function getCurrencySymbol(currency: string): string {
return (
BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] ||
currency.toUpperCase()
BILLING_CONFIG.CURRENCY_SYMBOLS[
currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS
] || currency.toUpperCase()
);
}
@@ -7,12 +7,17 @@ export interface BackendNotReadyError extends Error {
}
export function createBackendNotReadyError(): BackendNotReadyError {
return Object.assign(new Error(i18n.t("backendHealth.starting", "Backend starting up...")), {
code: BACKEND_NOT_READY_CODE,
});
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 {
export function isBackendNotReadyError(
error: unknown,
): error is BackendNotReadyError {
return (
typeof error === "object" &&
error !== null &&
+4 -2
View File
@@ -6,10 +6,12 @@
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL;
// Stirling SaaS backend API server (for team endpoints, etc.)
export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env.VITE_SAAS_BACKEND_API_URL;
export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env
.VITE_SAAS_BACKEND_API_URL;
// Supabase publishable key — used for SaaS authentication
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
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";
@@ -9,4 +9,5 @@ export const CREDIT_EVENTS = {
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];
@@ -1,4 +1,10 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
import React, {
createContext,
useContext,
useState,
ReactNode,
useCallback,
} from "react";
import { SaaSStripeCheckout } from "@app/components/shared/billing/SaaSStripeCheckout";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
@@ -9,7 +15,9 @@ interface SaaSCheckoutContextType {
closeCheckout: () => void;
}
const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(undefined);
const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(
undefined,
);
export const useSaaSCheckout = () => {
const context = useContext(SaaSCheckoutContext);
@@ -23,7 +31,9 @@ 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);
@@ -48,7 +58,10 @@ export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({ chil
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]);
@@ -62,7 +75,12 @@ export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({ chil
}}
>
{children}
<SaaSStripeCheckout opened={opened} onClose={closeCheckout} planId={selectedPlan} onSuccess={handleCheckoutSuccess} />
<SaaSStripeCheckout
opened={opened}
onClose={closeCheckout}
planId={selectedPlan}
onSuccess={handleCheckoutSuccess}
/>
</SaaSCheckoutContext.Provider>
);
};
@@ -1,4 +1,11 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react";
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";
@@ -81,7 +88,9 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const [teams, setTeams] = useState<Team[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
const [receivedInvitations, setReceivedInvitations] = useState<TeamInvitation[]>([]);
const [receivedInvitations, setReceivedInvitations] = useState<
TeamInvitation[]
>([]);
const [loading, setLoading] = useState(true);
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -98,7 +107,8 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
checkAccess();
// Subscribe to connection mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
const unsubscribe =
connectionModeService.subscribeToModeChanges(checkAccess);
return unsubscribe;
}, []);
@@ -113,12 +123,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];
@@ -140,12 +154,17 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
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");
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 });
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);
@@ -162,12 +181,18 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
}
try {
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`, {
suppressErrorToast: true,
});
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);
console.error(
"[SaaSTeamContext] Failed to fetch team invitations:",
error,
);
}
},
[isSaasMode, isAuthenticated],
@@ -182,11 +207,20 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
console.log("[SaaSTeamContext] Fetching received team invitations");
try {
const response = await apiClient.get<TeamInvitation[]>("/api/v1/team/invitations/pending", { 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]);
@@ -206,7 +240,12 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations]);
useEffect(() => {
if (currentTeam && !currentTeam.isPersonal && isSaasMode && isAuthenticated) {
if (
currentTeam &&
!currentTeam.isPersonal &&
isSaasMode &&
isAuthenticated
) {
fetchTeamMembers(currentTeam.teamId);
// Only fetch invitations if user is team leader
if (currentTeam.isLeader) {
@@ -219,7 +258,13 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
setTeamInvitations([]);
}
setLoading(false);
}, [currentTeam, isSaasMode, isAuthenticated, fetchTeamMembers, fetchTeamInvitations]);
}, [
currentTeam,
isSaasMode,
isAuthenticated,
fetchTeamMembers,
fetchTeamInvitations,
]);
const inviteUser = async (email: string) => {
if (!currentTeam) throw new Error("No current team");
@@ -261,7 +306,9 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
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 apiClient.delete(
`/api/v1/team/${currentTeam.teamId}/members/${memberId}`,
);
await refreshTeams();
await fetchTeamMembers(currentTeam.teamId);
};
@@ -277,7 +324,9 @@ 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;
}
@@ -290,7 +339,14 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
await fetchTeamInvitations(newCurrentTeam.teamId);
}
}
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations, fetchTeamMembers, fetchTeamInvitations]);
}, [
isSaasMode,
isAuthenticated,
fetchMyTeams,
fetchReceivedInvitations,
fetchTeamMembers,
fetchTeamInvitations,
]);
const isTeamLeader = currentTeam?.isLeader ?? false;
const isPersonalTeam = currentTeam?.isPersonal ?? true;
@@ -1,5 +1,18 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } from "react";
import { saasBillingService, BillingStatus, PlanPrice } from "@app/services/saasBillingService";
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";
@@ -80,7 +93,9 @@ const SaasBillingContext = createContext<SaasBillingContextType>({
});
export function SaasBillingProvider({ children }: { children: ReactNode }) {
const [billingStatus, setBillingStatus] = useState<BillingStatus | null>(null);
const [billingStatus, setBillingStatus] = useState<BillingStatus | null>(
null,
);
const [plans, setPlans] = useState<Map<string, PlanPrice>>(new Map());
const [plansLoading, setPlansLoading] = useState(false);
const [plansError, setPlansError] = useState<string | null>(null);
@@ -89,24 +104,35 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
// 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.
const lastFetchTimeRef = useRef<number | null>(null);
const [lastFetchTimeValue, setLastFetchTimeValue] = useState<number | null>(null);
const [lastFetchTimeValue, setLastFetchTimeValue] = useState<number | null>(
null,
);
// billingStatusRef mirrors billingStatus so fetchBillingData can read the current value
// without needing billingStatus in its useCallback dep array.
const billingStatusRef = useRef<BillingStatus | null>(null);
// plansLastFetchTimeRef is the source of truth for timing; plansLastFetchTimeValue
// is the state mirror exposed via context so consumers can react to it.
const plansLastFetchTimeRef = useRef<number | null>(null);
const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState<number | null>(null);
const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState<
number | null
>(null);
// In-flight deduplication — prevents concurrent duplicate network requests.
const plansFetchInProgressRef = useRef(false);
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Access team context for derived state
const { currentTeam, isPersonalTeam, isTeamLeader, loading: teamLoading } = useSaaSTeam();
const {
currentTeam,
isPersonalTeam,
isTeamLeader,
loading: teamLoading,
} = useSaaSTeam();
// Compute derived state: user is managed member if in non-personal team but not leader
const isManagedTeamMember = currentTeam ? !isPersonalTeam && !isTeamLeader : false;
const isManagedTeamMember = currentTeam
? !isPersonalTeam && !isTeamLeader
: false;
// Check if in SaaS mode and authenticated (same pattern as SaaSTeamContext)
useEffect(() => {
@@ -120,7 +146,8 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
checkAccess();
// Subscribe to connection mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
const unsubscribe =
connectionModeService.subscribeToModeChanges(checkAccess);
return unsubscribe;
}, []);
@@ -151,7 +178,11 @@ 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;
}
@@ -199,7 +230,9 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
setPlansLastFetchTimeValue(now);
} catch (err) {
console.error("[SaasBillingContext] Failed to fetch plans:", err);
setPlansError(err instanceof Error ? err.message : "Failed to fetch plans");
setPlansError(
err instanceof Error ? err.message : "Failed to fetch plans",
);
} finally {
plansFetchInProgressRef.current = false;
setPlansLoading(false);
@@ -237,7 +270,13 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
) {
fetchBillingData();
}
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, fetchBillingData]);
}, [
isSaasMode,
isAuthenticated,
teamLoading,
isManagedTeamMember,
fetchBillingData,
]);
// Public refresh methods
const refreshBilling = useCallback(async () => {
@@ -282,7 +321,10 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
window.dispatchEvent(
new CustomEvent("credits:exhausted", {
detail: { previousBalance: billingStatus.creditBalance ?? 0, currentBalance: newBalance },
detail: {
previousBalance: billingStatus.creditBalance ?? 0,
currentBalance: newBalance,
},
}),
);
}
@@ -336,7 +378,11 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
],
);
return <SaasBillingContext.Provider value={contextValue}>{children}</SaasBillingContext.Provider>;
return (
<SaasBillingContext.Provider value={contextValue}>
{children}
</SaasBillingContext.Provider>
);
}
export function useSaaSBilling() {
@@ -349,12 +395,21 @@ export function useSaaSBilling() {
// 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();
}
}, [context.billingStatus, context.lastFetchTime, context.loading, context.isManagedTeamMember, context.refreshBilling]);
}, [
context.billingStatus,
context.lastFetchTime,
context.loading,
context.isManagedTeamMember,
context.refreshBilling,
]);
return context;
}
@@ -365,10 +420,13 @@ export function useSaaSBilling() {
* Safe to call from multiple components — in-flight deduplication is handled by fetchPlansData.
*/
export function usePlanPricing() {
const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } = useContext(SaasBillingContext);
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();
@@ -11,13 +11,19 @@ interface AccountLogoutDeps {
* Desktop-specific logout: mirrors Connection Settings flow to avoid stale state.
*/
export function useAccountLogout() {
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
return async ({
signOut,
redirectToLogin,
}: AccountLogoutDeps): Promise<void> => {
try {
await signOut();
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) {
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
@@ -28,7 +34,10 @@ export function useAccountLogout() {
// 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,18 @@
*/
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 }, "*");
} 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
@@ -7,7 +7,10 @@ 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 +18,33 @@ 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,
);
}
}
@@ -4,9 +4,13 @@ import { connectionModeService } from "@app/services/connectionModeService";
/**
* Desktop-specific OAuth navigation: prefer popup/system browser, avoid hijacking main webview.
*/
export async function startOAuthNavigation(redirectUrl: string): Promise<boolean> {
export async function startOAuthNavigation(
redirectUrl: string,
): Promise<boolean> {
try {
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
const currentConfig = await connectionModeService
.getCurrentConfig()
.catch(() => null);
const serverUrl = currentConfig?.server_config?.url;
if (!serverUrl) {
return false;
@@ -15,7 +15,11 @@ export function useAppInitialization(): void {
const { addFiles, updateStirlingFileStub } = useFileManagement();
// Handle files opened with app (Tauri mode)
const { openedFilePaths, loading: openedFileLoading, consumeOpenedFilePaths } = useOpenedFile();
const {
openedFilePaths,
loading: openedFileLoading,
consumeOpenedFilePaths,
} = useOpenedFile();
// Load opened files and add directly to FileContext
useEffect(() => {
@@ -33,12 +37,17 @@ export function useAppInitialization(): void {
await Promise.all(
filePaths.map(async (filePath) => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
const fileData =
await fileOpenService.readFileAsArrayBuffer(filePath);
if (!fileData) return null;
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: "application/pdf",
});
const file = new File(
[fileData.arrayBuffer],
fileData.fileName,
{
type: "application/pdf",
},
);
console.log("[Desktop] Loaded file:", fileData.fileName);
@@ -48,16 +57,27 @@ 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));
).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 quickKeyToPath = new Map(
loadedFiles.map((entry) => [entry.quickKey, entry.filePath]),
);
const addedFiles = await addFiles(filesArray, { selectFiles: true });
addedFiles.forEach((file) => {
@@ -67,7 +87,9 @@ export function useAppInitialization(): void {
}
});
console.log(`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`);
console.log(
`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`,
);
}
} catch (error) {
console.error("[Desktop] Failed to load opened files:", error);
@@ -75,7 +97,13 @@ export function useAppInitialization(): void {
};
loadOpenedFiles();
}, [openedFilePaths, openedFileLoading, addFiles, updateStirlingFileStub, consumeOpenedFilePaths]);
}, [
openedFilePaths,
openedFileLoading,
addFiles,
updateStirlingFileStub,
consumeOpenedFilePaths,
]);
}
export function useSetupCompletion(): (completed: boolean) => void {
+19 -6
View File
@@ -19,14 +19,22 @@ import type { BackendHealthState } from "@app/types/backendHealth";
* - Self-hosted mode (server offline, local port unknown): false
*/
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 [health, setHealth] = useState<BackendHealthState>(() =>
backendHealthMonitor.getSnapshot(),
);
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(() => {
@@ -34,7 +42,9 @@ export function useBackendHealth() {
}, []);
useEffect(() => {
return selfHostedServerMonitor.subscribe((state) => setServerStatus(state.status));
return selfHostedServerMonitor.subscribe((state) =>
setServerStatus(state.status),
);
}, []);
useEffect(() => {
@@ -47,7 +57,10 @@ 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,
@@ -39,7 +39,9 @@ export function useConversionCloudStatus(): ConversionStatus {
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] || {})) {
for (const toExt of Object.keys(
EXTENSION_TO_ENDPOINT[fromExt] || {},
)) {
const endpointName = getEndpointName(fromExt, toExt);
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
}
@@ -51,7 +53,11 @@ export function useConversionCloudStatus(): ConversionStatus {
pairs.map(async ([fromExt, toExt, endpointName]) => {
const key = `${fromExt}-${toExt}`;
try {
const supported = await endpointAvailabilityService.isEndpointSupportedLocally(endpointName, localUrl);
const supported =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
localUrl,
);
return { key, supported };
} catch {
return { key, supported: false };
@@ -103,20 +109,39 @@ export function useConversionCloudStatus(): ConversionStatus {
// In SaaS mode, everything is available (locally or via cloud routing).
// Only check local support to determine willUseCloud — the same approach
// used by useMultipleEndpointsEnabled's SaaS enhancement.
const availableLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl(),
);
return { key, isAvailable: true, willUseCloud: !availableLocally, localOnly: false };
const availableLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl(),
);
return {
key,
isAvailable: true,
willUseCloud: !availableLocally,
localOnly: false,
};
} catch (error) {
console.error(`[useConversionCloudStatus] Endpoint check failed for ${key}:`, error);
console.error(
`[useConversionCloudStatus] Endpoint check failed for ${key}:`,
error,
);
// On error, assume available via cloud (safe default in SaaS mode)
return { key, isAvailable: true, willUseCloud: true, localOnly: false };
return {
key,
isAvailable: true,
willUseCloud: true,
localOnly: false,
};
}
}),
);
for (const { key, isAvailable, willUseCloud: wuc, localOnly: lo } of results) {
for (const {
key,
isAvailable,
willUseCloud: wuc,
localOnly: lo,
} of results) {
availability[key] = isAvailable;
cloudStatus[key] = wuc;
localOnly[key] = lo;
+8 -4
View File
@@ -52,10 +52,14 @@ export function useCreditCheck(operationType?: string, endpoint?: string) {
}),
);
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;
@@ -21,7 +21,10 @@ export function useCreditEvents() {
if (isSaaSMode && creditBalance <= 0 && prevBalance > 0) {
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: { previousBalance: prevBalance, currentBalance: creditBalance },
detail: {
previousBalance: prevBalance,
currentBalance: creditBalance,
},
}),
);
}
+12 -3
View File
@@ -30,14 +30,20 @@ export const useDefaultApp = () => {
alert({
alertType: "success",
title: t("defaultApp.success.title", "Default App Set"),
body: t("defaultApp.success.message", "Stirling PDF is now your default PDF editor"),
body: t(
"defaultApp.success.message",
"Stirling PDF is now your default PDF editor",
),
});
setIsDefault(true);
} 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"),
body: t(
"defaultApp.settingsOpened.message",
"Please select Stirling PDF in the file association dialogue",
),
});
}
} catch (error) {
@@ -45,7 +51,10 @@ export const useDefaultApp = () => {
alert({
alertType: "error",
title: t("defaultApp.error.title", "Error"),
body: t("defaultApp.error.message", "Failed to set default PDF handler"),
body: t(
"defaultApp.error.message",
"Failed to set default PDF handler",
),
});
} finally {
setIsLoading(false);
@@ -33,17 +33,22 @@ export function useEnableMeteredBilling(
throw new Error("Not authenticated");
}
const { data, error } = await supabase.functions.invoke("create-meter-subscription", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
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");
}
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 +56,8 @@ 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 {
+61 -19
View File
@@ -17,7 +17,10 @@ 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 {
@@ -36,9 +39,12 @@ function getErrorMessage(err: unknown): string {
async function checkDependenciesReady(): Promise<boolean> {
try {
const response = await apiClient.get<AppConfig>("/api/v1/config/app-config", {
suppressErrorToast: true,
});
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);
@@ -110,7 +116,9 @@ export function useEndpointEnabled(endpoint: string): {
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
// Even if not supported locally, they will route to SaaS backend
if (mode === "saas") {
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
console.debug(
`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
);
setEnabled(true);
return;
}
@@ -133,7 +141,9 @@ export function useEndpointEnabled(endpoint: string): {
// DESKTOP ENHANCEMENT: In SaaS mode, assume available even on check failure
const mode = await connectionModeService.getCurrentMode();
if (mode === "saas") {
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
console.debug(
`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
);
setEnabled(true); // Available via SaaS
setError(null);
return;
@@ -199,8 +209,12 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
refetch: () => Promise<void>;
} {
const { t } = useTranslation();
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(
{},
);
const [endpointDetails, setEndpointDetails] = useState<
Record<string, EndpointAvailabilityDetails>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
@@ -237,7 +251,11 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const results = await Promise.all(
[...new Set(endpoints)].map(async (ep) => {
try {
const supported = await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl);
const supported =
await endpointAvailabilityService.isEndpointSupportedLocally(
ep,
localUrl,
);
return { ep, supported };
} catch {
return { ep, supported: false };
@@ -249,7 +267,10 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
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 }));
@@ -268,17 +289,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// Try new API first (no params — new servers return all endpoints).
// Fall back to the old ?endpoints= form for servers that predate the
// "large query reduction" change and still require the parameter.
let response: Awaited<ReturnType<typeof apiClient.get<Record<string, EndpointAvailabilityDetails>>>>;
let response: Awaited<
ReturnType<
typeof apiClient.get<Record<string, EndpointAvailabilityDetails>>
>
>;
try {
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability`, {
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");
console.debug(
"[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format",
);
const endpointsParam = endpoints.join(",");
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
response = await apiClient.get<
Record<string, EndpointAvailabilityDetails>
>(
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
{ suppressErrorToast: true },
);
@@ -311,7 +342,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// 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);
const disabledEndpoints = Object.keys(details).filter(
(key) => !details[key].enabled,
);
for (const endpoint of disabledEndpoints) {
console.debug(
@@ -340,19 +373,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setError(message);
const fallbackStatus = endpoints.reduce(
(acc, endpointName) => {
const fallbackDetail: EndpointAvailabilityDetails = { enabled: false, reason: "UNKNOWN" };
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> },
{
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") {
for (const endpoint of endpoints) {
console.debug(`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
console.debug(
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
);
fallbackStatus.status[endpoint] = true;
fallbackStatus.details[endpoint] = { enabled: true, reason: null };
}
@@ -409,7 +450,8 @@ 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.
+31 -13
View File
@@ -18,7 +18,9 @@ export function useExitWarning() {
useEffect(() => {
const appWindow = getCurrentWindow();
const handleCloseRequested = async (event: { preventDefault: () => void }) => {
const handleCloseRequested = async (event: {
preventDefault: () => void;
}) => {
event.preventDefault();
if (isClosingRef.current) {
@@ -31,15 +33,22 @@ export function useExitWarning() {
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 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",
@@ -62,11 +71,18 @@ 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;
}
@@ -93,7 +109,9 @@ export function useExitWarning() {
}, [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;
@@ -11,7 +11,10 @@ export function useFileActionTerminology() {
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"),
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"),
@@ -19,6 +22,9 @@ export function useFileActionTerminology() {
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."),
noFilesInStorage: t(
"fileUpload.noFilesInStorageOpen",
"No files available in storage. Open some files first.",
),
};
}
@@ -7,7 +7,10 @@ import { authService } from "@app/services/authService";
* Checks if this is the first time the app is being launched
* Does not require FileContext - can be used early in the provider hierarchy
*/
export function useFirstLaunchCheck(): { isFirstLaunch: boolean; setupComplete: boolean } {
export function useFirstLaunchCheck(): {
isFirstLaunch: boolean;
setupComplete: boolean;
} {
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCheckCompleteRef = useRef(false);
@@ -2,7 +2,9 @@ import { useState, useEffect } from "react";
import { getVersion } from "@tauri-apps/api/app";
import type { FrontendVersionInfo } from "@core/hooks/useFrontendVersionInfo";
export function useFrontendVersionInfo(backendVersion: string | undefined): FrontendVersionInfo {
export function useFrontendVersionInfo(
backendVersion: string | undefined,
): FrontendVersionInfo {
const [appVersion, setAppVersion] = useState<string | null>(null);
const [mismatchVersion, setMismatchVersion] = useState(false);
@@ -15,7 +17,10 @@ export function useFrontendVersionInfo(backendVersion: string | undefined): Fron
setAppVersion(version);
}
} catch (error) {
console.error("[useFrontendVersionInfo] Failed to fetch frontend version:", error);
console.error(
"[useFrontendVersionInfo] Failed to fetch frontend version:",
error,
);
}
};
fetchVersion();
@@ -30,10 +35,13 @@ export function useFrontendVersionInfo(backendVersion: string | undefined): Fron
return;
}
if (appVersion !== backendVersion) {
console.warn("[useFrontendVersionInfo] Mismatch between frontend version and AppConfig version:", {
backendVersion,
frontendVersion: appVersion,
});
console.warn(
"[useFrontendVersionInfo] Mismatch between frontend version and AppConfig version:",
{
backendVersion,
frontendVersion: appVersion,
},
);
setMismatchVersion(true);
} else {
setMismatchVersion(false);
@@ -4,7 +4,8 @@ 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
@@ -29,13 +30,18 @@ export function useGroupEnabled(group: string): GroupEnabledResult {
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)}`)
.get<boolean>(
`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`,
)
.then((res) => setResult({ enabled: res.data, unavailableReason: null }))
.catch(() => setResult({ enabled: false, unavailableReason: null }));
}, [group, t]);
@@ -8,5 +8,9 @@ import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
export function useGroupSigningEnabled(): boolean {
const { config } = useAppConfig();
const { isSelfHosted, isAuthenticated } = useSelfHostedAuth();
return isSelfHosted && isAuthenticated && config?.storageGroupSigningEnabled === true;
return (
isSelfHosted &&
isAuthenticated &&
config?.storageGroupSigningEnabled === true
);
}
+10 -2
View File
@@ -28,7 +28,10 @@ export function useOpenedFile() {
console.log("🔍 fileOpenService.getOpenedFiles() returned:", filePaths);
if (filePaths.length > 0) {
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
console.log(
`✅ Found ${filePaths.length} file(s) in storage:`,
filePaths,
);
openedFilePathsRef.current = filePaths;
setOpenedFilePaths(filePaths);
}
@@ -57,5 +60,10 @@ export function useOpenedFile() {
};
}, []);
return { openedFilePaths, loading, clearOpenedFilePaths, consumeOpenedFilePaths };
return {
openedFilePaths,
loading,
clearOpenedFilePaths,
consumeOpenedFilePaths,
};
}
+6 -2
View File
@@ -10,8 +10,12 @@ 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;
+86 -22
View File
@@ -1,7 +1,14 @@
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 {
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 {
@@ -37,14 +44,31 @@ export const useSaaSPlans = () => {
price: 0,
currency: "$",
period: t("plan.period.month", "/month"),
highlights: FREE_PLAN_FEATURES.map((f) => t(f.translationKey, f.defaultText)),
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.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.priority", "Priority Support"),
included: false,
},
{
name: t("plan.feature.customPricing", "Custom Pricing"),
included: false,
},
],
},
{
@@ -55,14 +79,34 @@ export const useSaaSPlans = () => {
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,
},
],
},
{
@@ -72,14 +116,34 @@ export const useSaaSPlans = () => {
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,
},
],
},
];
@@ -19,9 +19,14 @@ export function useSaveShortcut() {
// 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 filesToSave =
selectedFileIds.length > 0
? selectors.getFiles(selectedFileIds)
: selectors.getFiles();
const stubsToSave =
selectedFileIds.length > 0 ? selectors.getStirlingFileStubs(selectedFileIds) : selectors.getStirlingFileStubs();
selectedFileIds.length > 0
? selectors.getStirlingFileStubs(selectedFileIds)
: selectors.getStirlingFileStubs();
if (filesToSave.length === 0) {
return;
@@ -21,13 +21,19 @@ 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(() => {
@@ -14,7 +14,9 @@ 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);
@@ -52,7 +54,12 @@ export function useSelfHostedToolAvailability(tools: Array<{ id: string; endpoin
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)) {
@@ -34,7 +34,10 @@ export function useToolCloudStatus(endpointName?: string): boolean {
}
// Check if supported on SaaS first (if not, no point showing cloud badge)
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointName);
const supportedOnSaaS =
await endpointAvailabilityService.isEndpointSupportedOnSaaS(
endpointName,
);
if (!supportedOnSaaS) {
// Not available on SaaS, don't show cloud badge
@@ -43,10 +46,11 @@ export function useToolCloudStatus(endpointName?: string): boolean {
}
// Available on SaaS, check if also available locally
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl(),
);
const supportedLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl(),
);
// Show cloud badge only if SaaS supports it but local doesn't
setUsesCloud(!supportedLocally);
} catch {
@@ -29,7 +29,11 @@ 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);
}
};
@@ -13,14 +13,41 @@ interface LoginHeaderProps {
* Desktop override of LoginHeader.
* Renders icon + title + optional close button all in one row.
*/
export default function LoginHeader({ title, subtitle, centerOnly = false, onClose }: LoginHeaderProps) {
export default function LoginHeader({
title,
subtitle,
centerOnly = false,
onClose,
}: LoginHeaderProps) {
const { tooltipLogo } = useLogoAssets();
return (
<div className={`login-header${centerOnly ? " login-header-centered" : ""}`} style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flex: 1, minWidth: 0 }}>
<img src={tooltipLogo} alt="Stirling PDF" style={{ width: 36, height: 36, flexShrink: 0 }} />
<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}
@@ -33,7 +60,11 @@ 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>
@@ -15,7 +15,10 @@ 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;
}
+38 -11
View File
@@ -6,7 +6,10 @@ 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 {
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";
@@ -42,7 +45,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Pattern matching in shouldSkipBackendReadyCheck() needs original relative URL
const originalUrl = extendedConfig.url;
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const skipForSaaSBackend = await operationRouter.shouldSkipBackendReadyCheck(originalUrl);
const skipForSaaSBackend =
await operationRouter.shouldSkipBackendReadyCheck(originalUrl);
try {
// Get the appropriate base URL for this request
@@ -81,9 +85,13 @@ export function setupApiInterceptors(client: AxiosInstance): void {
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
console.debug(`[apiClientSetup] Added auth token for request to: ${extendedConfig.url}`);
console.debug(
`[apiClientSetup] Added auth token for request to: ${extendedConfig.url}`,
);
} else {
console.warn(`[apiClientSetup] No auth token available for: ${extendedConfig.url}`);
console.warn(
`[apiClientSetup] No auth token available for: ${extendedConfig.url}`,
);
}
} else {
// Local bundled backend: disable credentials (security disabled)
@@ -114,7 +122,10 @@ export function setupApiInterceptors(client: AxiosInstance): void {
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."),
body: i18n.t(
"backendHealth.wait",
"Please wait for the backend to finish launching and try again.",
),
isPersistentPopup: false,
});
}
@@ -162,7 +173,12 @@ export function setupApiInterceptors(client: AxiosInstance): void {
return Promise.reject(error);
}
if (typeof window !== "undefined") {
console.warn("[apiClientSetup] 401 on path:", window.location.pathname, "url:", originalRequest.url);
console.warn(
"[apiClientSetup] 401 on path:",
window.location.pathname,
"url:",
originalRequest.url,
);
}
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
@@ -175,7 +191,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
}
originalRequest._retry = true;
console.debug(`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`);
console.debug(
`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`,
);
const isRemote = await operationRouter.isSelfHostedMode();
let refreshed = false;
@@ -194,19 +212,25 @@ export function setupApiInterceptors(client: AxiosInstance): void {
if (refreshed) {
// Retry the original request with new token
const token = await authService.getAuthToken();
console.debug(`[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`);
console.debug(
`[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`,
);
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
} else {
console.error(`[apiClientSetup] No token available after successful refresh!`);
console.error(
`[apiClientSetup] No token available after successful refresh!`,
);
}
return client.request(originalRequest);
}
// Refresh failed - prompt for re-authentication via the sign-in modal.
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }));
window.dispatchEvent(
new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }),
);
}
// Handle 403 Forbidden - unauthorized access
@@ -214,7 +238,10 @@ export function setupApiInterceptors(client: AxiosInstance): void {
alert({
alertType: "error",
title: i18n.t("auth.accessDenied", "Access Denied"),
body: i18n.t("auth.insufficientPermissions", "You do not have permission to perform this action."),
body: i18n.t(
"auth.insufficientPermissions",
"You do not have permission to perform this action.",
),
isPersistentPopup: false,
});
}
+347 -104
View File
@@ -5,7 +5,11 @@ import { connectionModeService } from "@app/services/connectionModeService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import axios from "axios";
import tauriHttpClient from "@app/services/tauriHttpClient";
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from "@app/constants/connection";
import {
DESKTOP_DEEP_LINK_CALLBACK,
STIRLING_SAAS_URL,
SUPABASE_KEY,
} from "@app/constants/connection";
export interface UserInfo {
username: string;
@@ -33,7 +37,11 @@ interface OAuthCallbackResult {
expires_in: number | null;
}
export type AuthStatus = "authenticated" | "unauthenticated" | "refreshing" | "oauth_pending";
export type AuthStatus =
| "authenticated"
| "unauthenticated"
| "refreshing"
| "oauth_pending";
export class AuthService {
private static instance: AuthService;
@@ -41,7 +49,9 @@ export class AuthService {
private userInfo: UserInfo | null = null;
private cachedToken: string | null = null;
private lastTokenSaveTime: number = 0;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
private authListeners = new Set<
(status: AuthStatus, userInfo: UserInfo | null) => void
>();
private refreshPromise: Promise<boolean> | null = null;
private selfHostedDeepLinkFlowActive = false;
@@ -55,10 +65,16 @@ export class AuthService {
/**
* Save token to all storage locations and notify listeners
*/
private async saveTokenEverywhere(token: string, refreshToken?: string | null, emitJwtAvailable = true): Promise<void> {
private async saveTokenEverywhere(
token: string,
refreshToken?: string | null,
emitJwtAvailable = true,
): Promise<void> {
// Validate token before caching
if (!token || token.trim().length === 0) {
console.warn("[Desktop AuthService] Attempted to save invalid/empty token");
console.warn(
"[Desktop AuthService] Attempted to save invalid/empty token",
);
throw new Error("Invalid token");
}
@@ -66,7 +82,10 @@ export class AuthService {
try {
await invoke("save_auth_token", { token });
} catch (error) {
console.error("[Desktop AuthService] Failed to save token to Tauri store:", error);
console.error(
"[Desktop AuthService] Failed to save token to Tauri store:",
error,
);
// Don't throw - we can still use localStorage
}
@@ -74,7 +93,10 @@ export class AuthService {
try {
localStorage.setItem("stirling_jwt", token);
} catch (error) {
console.error("[Desktop AuthService] Failed to save token to localStorage:", error);
console.error(
"[Desktop AuthService] Failed to save token to localStorage:",
error,
);
}
// Cache the valid token in memory
@@ -88,7 +110,10 @@ export class AuthService {
// Only remove from localStorage after successful save
localStorage.removeItem("stirling_refresh_token");
} catch (error) {
console.error("[Desktop AuthService] Failed to save refresh token:", error);
console.error(
"[Desktop AuthService] Failed to save refresh token:",
error,
);
}
}
@@ -109,7 +134,10 @@ export class AuthService {
return token;
}
} catch (error) {
console.error("[Desktop AuthService] Failed to read from Tauri store:", error);
console.error(
"[Desktop AuthService] Failed to read from Tauri store:",
error,
);
}
// Fallback to localStorage
@@ -134,13 +162,19 @@ export class AuthService {
try {
await invoke("clear_auth_token");
} catch (error) {
console.warn("[Desktop AuthService] Failed to clear Tauri keyring access token", error);
console.warn(
"[Desktop AuthService] Failed to clear Tauri keyring access token",
error,
);
}
try {
await invoke("clear_refresh_token");
} catch (error) {
console.warn("[Desktop AuthService] Failed to clear Tauri keyring refresh token", error);
console.warn(
"[Desktop AuthService] Failed to clear Tauri keyring refresh token",
error,
);
}
// Best effort: clear web storage
@@ -148,7 +182,10 @@ export class AuthService {
localStorage.removeItem("stirling_jwt");
localStorage.removeItem("stirling_refresh_token");
} catch (error) {
console.warn("[Desktop AuthService] Failed to clear localStorage tokens", error);
console.warn(
"[Desktop AuthService] Failed to clear localStorage tokens",
error,
);
}
}
@@ -165,7 +202,9 @@ export class AuthService {
this.setAuthStatus("unauthenticated", null);
}
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
subscribeToAuth(
listener: (status: AuthStatus, userInfo: UserInfo | null) => void,
): () => void {
this.authListeners.add(listener);
// Immediately notify new listener of current state
listener(this.authStatus, this.userInfo);
@@ -179,7 +218,9 @@ export class AuthService {
}
private notifyListeners() {
this.authListeners.forEach((listener) => listener(this.authStatus, this.userInfo));
this.authListeners.forEach((listener) =>
listener(this.authStatus, this.userInfo),
);
}
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
@@ -198,15 +239,25 @@ export class AuthService {
// (e.g. after completing re-auth via the sign-in modal). This prevents the
// simulation from looping: expired → modal → re-auth → expired → modal…
if (this.authStatus === "authenticated") return false;
return import.meta.env.DEV && String(import.meta.env.VITE_DEV_SIMULATE_EXPIRED_JWT ?? "").toLowerCase() === "true";
return (
import.meta.env.DEV &&
String(
import.meta.env.VITE_DEV_SIMULATE_EXPIRED_JWT ?? "",
).toLowerCase() === "true"
);
}
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
async completeSupabaseSession(
accessToken: string,
serverUrl: string,
): Promise<UserInfo> {
if (!accessToken || !accessToken.trim()) {
throw new Error("Invalid access token");
}
if (!SUPABASE_KEY) {
throw new Error("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured");
throw new Error(
"VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured",
);
}
await this.saveTokenEverywhere(accessToken);
@@ -227,7 +278,9 @@ export class AuthService {
throw new Error("VITE_SAAS_SERVER_URL is not configured");
}
if (!SUPABASE_KEY) {
throw new Error("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured");
throw new Error(
"VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured",
);
}
const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK);
@@ -258,11 +311,18 @@ export class AuthService {
error.message;
throw new Error(message || "Sign up failed", { cause: error });
}
throw error instanceof Error ? error : new Error("Sign up failed", { cause: error });
throw error instanceof Error
? error
: new Error("Sign up failed", { cause: error });
}
}
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
async login(
serverUrl: string,
username: string,
password: string,
mfaCode?: string,
): Promise<UserInfo> {
try {
// Validate SaaS configuration if connecting to SaaS
if (serverUrl === STIRLING_SAAS_URL) {
@@ -270,7 +330,9 @@ export class AuthService {
throw new Error("VITE_SAAS_SERVER_URL is not configured");
}
if (!SUPABASE_KEY) {
throw new Error("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured");
throw new Error(
"VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured",
);
}
}
@@ -291,7 +353,9 @@ export class AuthService {
await this.saveTokenEverywhere(token);
} catch (error) {
console.error("[Desktop AuthService] Failed to save token:", error);
throw new Error("Failed to save authentication token", { cause: error });
throw new Error("Failed to save authentication token", {
cause: error,
});
}
// Save user info to store
@@ -318,34 +382,56 @@ export class AuthService {
if (errMsg.includes("mfa_required")) {
this.setAuthStatus("unauthenticated", null);
throw new AuthServiceError("Two-factor code required.", "mfa_required");
throw new AuthServiceError(
"Two-factor code required.",
"mfa_required",
);
}
if (errMsg.includes("invalid_mfa_code")) {
this.setAuthStatus("unauthenticated", null);
throw new AuthServiceError("Invalid two-factor code.", "invalid_mfa_code");
throw new AuthServiceError(
"Invalid two-factor code.",
"invalid_mfa_code",
);
}
// Authentication errors
if (errMsg.includes("401") || errMsg.includes("unauthorized") || errMsg.includes("invalid credentials")) {
if (
errMsg.includes("401") ||
errMsg.includes("unauthorized") ||
errMsg.includes("invalid credentials")
) {
this.setAuthStatus("unauthenticated", null);
throw new Error("Invalid username or password. Please check your credentials and try again.", {
cause: error,
});
throw new Error(
"Invalid username or password. Please check your credentials and try again.",
{
cause: error,
},
);
}
// Server not found or unreachable
else if (errMsg.includes("connection refused") || errMsg.includes("econnrefused")) {
else if (
errMsg.includes("connection refused") ||
errMsg.includes("econnrefused")
) {
this.setAuthStatus("unauthenticated", null);
throw new Error("Cannot connect to server. Please check the server URL and ensure the server is running.", {
cause: error,
});
throw new Error(
"Cannot connect to server. Please check the server URL and ensure the server is running.",
{
cause: error,
},
);
}
// Timeout
else if (errMsg.includes("timeout") || errMsg.includes("timed out")) {
this.setAuthStatus("unauthenticated", null);
throw new Error("Login request timed out. Please check your network connection and try again.", {
cause: error,
});
throw new Error(
"Login request timed out. Please check your network connection and try again.",
{
cause: error,
},
);
}
// DNS failure
else if (
@@ -355,7 +441,10 @@ export class AuthService {
errMsg.includes("enotfound")
) {
this.setAuthStatus("unauthenticated", null);
throw new Error("Cannot resolve server address. Please check the server URL is correct.", { cause: error });
throw new Error(
"Cannot resolve server address. Please check the server URL is correct.",
{ cause: error },
);
}
// SSL/TLS errors
else if (
@@ -365,23 +454,32 @@ export class AuthService {
errMsg.includes("cert")
) {
this.setAuthStatus("unauthenticated", null);
throw new Error("SSL/TLS certificate error. Server may have an invalid or self-signed certificate.", {
cause: error,
});
throw new Error(
"SSL/TLS certificate error. Server may have an invalid or self-signed certificate.",
{
cause: error,
},
);
}
// 404 - endpoint not found
else if (errMsg.includes("404") || errMsg.includes("not found")) {
this.setAuthStatus("unauthenticated", null);
throw new Error("Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.", {
cause: error,
});
throw new Error(
"Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.",
{
cause: error,
},
);
}
// 403 - security disabled
else if (errMsg.includes("403") || errMsg.includes("forbidden")) {
this.setAuthStatus("unauthenticated", null);
throw new Error("Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).", {
cause: error,
});
throw new Error(
"Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).",
{
cause: error,
},
);
}
}
@@ -403,13 +501,17 @@ export class AuthService {
try {
// Best-effort backend logout so any server-side session/cookies are cleared
try {
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
const currentConfig = await connectionModeService
.getCurrentConfig()
.catch(() => null);
const serverUrl = currentConfig?.server_config?.url;
const token = await this.getAuthToken();
if (serverUrl && token) {
const base = serverUrl.replace(/\/+$/, "");
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
// Treat 401/403 as benign (session already expired)
const safePost = async (url: string) => {
@@ -420,10 +522,15 @@ export class AuthService {
validateStatus: () => true, // handle status manually
});
if (resp.status >= 400 && ![401, 403].includes(resp.status)) {
console.warn(`[Desktop AuthService] Logout call to ${url} failed: ${resp.status}`);
console.warn(
`[Desktop AuthService] Logout call to ${url} failed: ${resp.status}`,
);
}
} catch (err) {
console.warn(`[Desktop AuthService] Backend logout failed via ${url}`, err);
console.warn(
`[Desktop AuthService] Backend logout failed via ${url}`,
err,
);
}
};
@@ -433,7 +540,10 @@ export class AuthService {
await safePost(`${base}/logout`);
}
} catch (err) {
console.warn("[Desktop AuthService] Failed to call backend logout endpoint", err);
console.warn(
"[Desktop AuthService] Failed to call backend logout endpoint",
err,
);
}
// Clear token from all storage locations
@@ -460,7 +570,9 @@ export class AuthService {
// Health checks run every 5s, so 30s leeway would cause 5-6 unnecessary cache clears
// The 30s leeway is used elsewhere for proactive refresh before user operations
if (this.isTokenExpiringSoon(this.cachedToken, 5)) {
console.warn("[Desktop AuthService] ⚠️ Cached token is expired or expiring soon, invalidating cache");
console.warn(
"[Desktop AuthService] ⚠️ Cached token is expired or expiring soon, invalidating cache",
);
this.cachedToken = null;
// Fall through to fetch from storage
} else {
@@ -469,7 +581,9 @@ export class AuthService {
}
}
console.debug("[Desktop AuthService] Cache miss, fetching from storage...");
console.debug(
"[Desktop AuthService] Cache miss, fetching from storage...",
);
const token = await this.getTokenFromAnySource();
// Cache token if found (backend will validate expiry)
@@ -481,7 +595,9 @@ export class AuthService {
return null;
}
this.cachedToken = token;
console.log("[Desktop AuthService] ✅ Token cached in memory after retrieval");
console.log(
"[Desktop AuthService] ✅ Token cached in memory after retrieval",
);
return token;
}
return null;
@@ -498,7 +614,10 @@ export class AuthService {
async getUserInfo(): Promise<UserInfo | null> {
if (this.userInfo) {
console.log("[Desktop AuthService] Using cached user info:", this.userInfo.username);
console.log(
"[Desktop AuthService] Using cached user info:",
this.userInfo.username,
);
return this.userInfo;
}
@@ -506,14 +625,20 @@ export class AuthService {
console.log("[Desktop AuthService] Retrieving user info from store...");
const userInfo = await invoke<UserInfo | null>("get_user_info");
if (userInfo) {
console.log("[Desktop AuthService] User info found:", userInfo.username);
console.log(
"[Desktop AuthService] User info found:",
userInfo.username,
);
this.userInfo = userInfo;
} else {
console.log("[Desktop AuthService] No user info in store");
}
return userInfo;
} catch (error) {
console.error("[Desktop AuthService] Failed to get user info from store:", error);
console.error(
"[Desktop AuthService] Failed to get user info from store:",
error,
);
return null;
}
}
@@ -523,10 +648,15 @@ export class AuthService {
return false;
}
try {
console.debug("[Desktop AuthService] Waiting for in-flight refresh to complete");
console.debug(
"[Desktop AuthService] Waiting for in-flight refresh to complete",
);
return await this.refreshPromise;
} catch (error) {
console.warn("[Desktop AuthService] In-flight refresh failed while waiting", error);
console.warn(
"[Desktop AuthService] In-flight refresh failed while waiting",
error,
);
return false;
}
}
@@ -541,7 +671,9 @@ export class AuthService {
try {
const parts = token.split(".");
if (parts.length < 2) {
console.warn("[Desktop AuthService] Token malformed - less than 2 parts");
console.warn(
"[Desktop AuthService] Token malformed - less than 2 parts",
);
return true;
}
@@ -580,7 +712,9 @@ export class AuthService {
async refreshToken(serverUrl: string): Promise<boolean> {
// Prevent concurrent refresh attempts - reuse in-flight refresh
if (this.refreshPromise) {
console.log("[Desktop AuthService] Refresh already in progress, awaiting existing refresh");
console.log(
"[Desktop AuthService] Refresh already in progress, awaiting existing refresh",
);
return this.refreshPromise;
}
@@ -615,10 +749,15 @@ export class AuthService {
},
);
const token = response.data?.session?.access_token ?? response.data?.access_token ?? response.data?.token;
const token =
response.data?.session?.access_token ??
response.data?.access_token ??
response.data?.token;
if (!token) {
console.error("[Desktop AuthService] Refresh response missing token payload");
console.error(
"[Desktop AuthService] Refresh response missing token payload",
);
this.setAuthStatus("unauthenticated", null);
await this.logout();
return false;
@@ -646,7 +785,9 @@ export class AuthService {
async refreshSupabaseToken(authServerUrl: string): Promise<boolean> {
// Prevent concurrent refresh attempts - reuse in-flight refresh
if (this.refreshPromise) {
console.log("[Desktop AuthService] Refresh already in progress, awaiting existing refresh");
console.log(
"[Desktop AuthService] Refresh already in progress, awaiting existing refresh",
);
return this.refreshPromise;
}
@@ -658,7 +799,9 @@ export class AuthService {
}
}
private async _doRefreshSupabaseToken(authServerUrl: string): Promise<boolean> {
private async _doRefreshSupabaseToken(
authServerUrl: string,
): Promise<boolean> {
try {
console.log("[Desktop AuthService] Refreshing Supabase token");
this.setAuthStatus("refreshing", this.userInfo);
@@ -692,10 +835,15 @@ export class AuthService {
const userInfo = await this.getUserInfo();
this.setAuthStatus("authenticated", userInfo);
console.log("[Desktop AuthService] Supabase token refreshed successfully");
console.log(
"[Desktop AuthService] Supabase token refreshed successfully",
);
return true;
} catch (error) {
console.error("[Desktop AuthService] Supabase token refresh failed:", error);
console.error(
"[Desktop AuthService] Supabase token refresh failed:",
error,
);
this.setAuthStatus("unauthenticated", null);
// Clear stored credentials on refresh failure
@@ -711,23 +859,35 @@ export class AuthService {
const path = typeof window !== "undefined" ? window.location.pathname : "";
if (path.startsWith("/login") || path.startsWith("/setup")) {
// Check if token exists in storage (user just logged in via web flow)
const tokenInStorage = typeof window !== "undefined" ? localStorage.getItem("stirling_jwt") : null;
const tokenInStorage =
typeof window !== "undefined"
? localStorage.getItem("stirling_jwt")
: null;
if (tokenInStorage) {
console.log("[Desktop AuthService] On login/setup path with token present - skipping validation");
console.log("[Desktop AuthService] Login flow will handle authentication state");
console.log(
"[Desktop AuthService] On login/setup path with token present - skipping validation",
);
console.log(
"[Desktop AuthService] Login flow will handle authentication state",
);
// Return early to avoid clearing partial state during login completion
// The login completion handler (completeSelfHostedSession) will:
// 1. Fetch and save user info
// 2. Set auth status to authenticated
return;
} else {
console.log("[Desktop AuthService] On login/setup path, clearing any cached auth");
console.log(
"[Desktop AuthService] On login/setup path, clearing any cached auth",
);
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
await this.clearTokenEverywhere().catch(() => {});
try {
await invoke("clear_user_info");
} catch (err) {
console.warn("[Desktop AuthService] Failed to clear user info on login/setup init", err);
console.warn(
"[Desktop AuthService] Failed to clear user info on login/setup init",
err,
);
}
this.setAuthStatus("unauthenticated", null);
return;
@@ -740,11 +900,15 @@ export class AuthService {
if (token && userInfo) {
console.log("[Desktop AuthService] Found existing token and user info");
this.setAuthStatus("authenticated", userInfo);
console.log("[Desktop AuthService] Auth state initialized as authenticated");
console.log(
"[Desktop AuthService] Auth state initialized as authenticated",
);
} else {
console.log("[Desktop AuthService] No token or user info found");
this.setAuthStatus("unauthenticated", null);
console.log("[Desktop AuthService] Auth state initialized as unauthenticated");
console.log(
"[Desktop AuthService] Auth state initialized as unauthenticated",
);
// Defensive: ensure any partial tokens are purged to prevent auto-login loops.
// Skip when simulating expiry — the token is real and must not be destroyed.
@@ -757,14 +921,24 @@ export class AuthService {
/**
* Start OAuth login flow by opening system browser with localhost callback
*/
async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise<UserInfo> {
async loginWithOAuth(
provider: string,
authServerUrl: string,
successHtml: string,
errorHtml: string,
): Promise<UserInfo> {
try {
console.log("[Desktop AuthService] Starting OAuth login with provider:", provider);
console.log(
"[Desktop AuthService] Starting OAuth login with provider:",
provider,
);
this.setAuthStatus("oauth_pending", null);
// Validate Supabase key is configured for OAuth
if (!SUPABASE_KEY) {
throw new Error("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured");
throw new Error(
"VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured",
);
}
// Call Rust command which:
@@ -780,16 +954,30 @@ export class AuthService {
errorHtml,
});
console.log("[Desktop AuthService] OAuth authentication successful, storing tokens");
console.log("[Desktop AuthService] OAuth result - has access_token:", !!result.access_token);
console.log("[Desktop AuthService] OAuth result - has refresh_token:", !!result.refresh_token);
console.log("[Desktop AuthService] OAuth result - expires_in:", result.expires_in);
console.log(
"[Desktop AuthService] OAuth authentication successful, storing tokens",
);
console.log(
"[Desktop AuthService] OAuth result - has access_token:",
!!result.access_token,
);
console.log(
"[Desktop AuthService] OAuth result - has refresh_token:",
!!result.refresh_token,
);
console.log(
"[Desktop AuthService] OAuth result - expires_in:",
result.expires_in,
);
// Save token and refresh token to all storage locations
await this.saveTokenEverywhere(result.access_token, result.refresh_token);
// Fetch user info from Supabase using the access token
const userInfo = await this.fetchSupabaseUserInfo(authServerUrl, result.access_token);
const userInfo = await this.fetchSupabaseUserInfo(
authServerUrl,
result.access_token,
);
// Save user info to store
await invoke("save_user_info", {
@@ -802,7 +990,10 @@ export class AuthService {
return userInfo;
} catch (error) {
console.error("[Desktop AuthService] Failed to complete OAuth login:", error);
console.error(
"[Desktop AuthService] Failed to complete OAuth login:",
error,
);
this.setAuthStatus("unauthenticated", null);
throw error;
}
@@ -812,11 +1003,16 @@ export class AuthService {
* Self-hosted SSO/OAuth2 flow for the desktop app.
1 * Opens the system browser and waits for a deep link callback with the JWT.
*/
async loginWithSelfHostedOAuth(providerPath: string, serverUrl: string): Promise<UserInfo> {
async loginWithSelfHostedOAuth(
providerPath: string,
serverUrl: string,
): Promise<UserInfo> {
// Generate and store nonce for CSRF protection
const nonce = crypto.randomUUID();
sessionStorage.setItem("oauth_nonce", nonce);
console.log("[Desktop AuthService] Generated OAuth nonce for CSRF protection");
console.log(
"[Desktop AuthService] Generated OAuth nonce for CSRF protection",
);
const trimmedServer = serverUrl.replace(/\/+$/, "");
const fullUrl = providerPath.startsWith("http")
@@ -835,7 +1031,9 @@ export class AuthService {
// Register deep-link listener before opening browser to avoid callback races on first launch.
return this.waitForDeepLinkCompletion(trimmedServer, async () => {
if (!(await this.openInSystemBrowser(authUrl))) {
throw new Error("Unable to open system browser for SSO. Please check your system settings.");
throw new Error(
"Unable to open system browser for SSO. Please check your system settings.",
);
}
});
}
@@ -843,9 +1041,14 @@ export class AuthService {
/**
* Wait for a deep-link event to complete self-hosted SSO after system browser OAuth
*/
private async waitForDeepLinkCompletion(serverUrl: string, startFlow?: () => Promise<void>): Promise<UserInfo> {
private async waitForDeepLinkCompletion(
serverUrl: string,
startFlow?: () => Promise<void>,
): Promise<UserInfo> {
if (!isTauri()) {
throw new Error("Deep link authentication is only supported in Tauri desktop app.");
throw new Error(
"Deep link authentication is only supported in Tauri desktop app.",
);
}
this.selfHostedDeepLinkFlowActive = true;
@@ -885,13 +1088,16 @@ export class AuthService {
if (type !== "sso" && type !== "sso-selfhosted") {
return;
}
const token = params.get("access_token") || parsed.searchParams.get("access_token");
const token =
params.get("access_token") ||
parsed.searchParams.get("access_token");
if (!token) {
return;
}
// CSRF Protection: Validate nonce before accepting token
const nonceFromUrl = params.get("nonce") || parsed.searchParams.get("nonce");
const nonceFromUrl =
params.get("nonce") || parsed.searchParams.get("nonce");
const storedNonce = sessionStorage.getItem("oauth_nonce");
if (!nonceFromUrl || !storedNonce || nonceFromUrl !== storedNonce) {
@@ -900,8 +1106,14 @@ export class AuthService {
clearTimeout(timeoutId);
sessionStorage.removeItem("oauth_nonce");
this.selfHostedDeepLinkFlowActive = false;
console.error("[Desktop AuthService] Nonce validation failed - potential CSRF attack");
reject(new Error("Invalid authentication state. Nonce validation failed."));
console.error(
"[Desktop AuthService] Nonce validation failed - potential CSRF attack",
);
reject(
new Error(
"Invalid authentication state. Nonce validation failed.",
),
);
return;
}
@@ -912,13 +1124,19 @@ export class AuthService {
this.selfHostedDeepLinkFlowActive = false;
console.log("[Desktop AuthService] Nonce validated successfully");
const userInfo = await this.completeSelfHostedSession(serverUrl, token);
const userInfo = await this.completeSelfHostedSession(
serverUrl,
token,
);
// Ensure connection mode is set and backend is ready (in case caller doesn't)
try {
await connectionModeService.switchToSelfHosted({ url: serverUrl });
await tauriBackendService.initializeExternalBackend();
} catch (e) {
console.warn("[Desktop AuthService] Failed to initialize backend after deep link:", e);
console.warn(
"[Desktop AuthService] Failed to initialize backend after deep link:",
e,
);
}
resolve(userInfo);
} catch (err) {
@@ -927,7 +1145,9 @@ export class AuthService {
clearTimeout(timeoutId);
sessionStorage.removeItem("oauth_nonce");
this.selfHostedDeepLinkFlowActive = false;
reject(err instanceof Error ? err : new Error("Failed to complete SSO"));
reject(
err instanceof Error ? err : new Error("Failed to complete SSO"),
);
}
})
.then(async (fn) => {
@@ -948,7 +1168,11 @@ export class AuthService {
clearTimeout(timeoutId);
sessionStorage.removeItem("oauth_nonce");
this.selfHostedDeepLinkFlowActive = false;
reject(err instanceof Error ? err : new Error("Failed to start SSO login"));
reject(
err instanceof Error
? err
: new Error("Failed to start SSO login"),
);
}
})
.catch((err) => {
@@ -960,7 +1184,11 @@ export class AuthService {
clearTimeout(timeoutId);
sessionStorage.removeItem("oauth_nonce");
this.selfHostedDeepLinkFlowActive = false;
reject(err instanceof Error ? err : new Error("Failed to listen for deep link events"));
reject(
err instanceof Error
? err
: new Error("Failed to listen for deep link events"),
);
});
});
}
@@ -982,7 +1210,10 @@ export class AuthService {
/**
* Save JWT + user info for self-hosted SSO logins
*/
async completeSelfHostedSession(serverUrl: string, token: string): Promise<UserInfo> {
async completeSelfHostedSession(
serverUrl: string,
token: string,
): Promise<UserInfo> {
const userInfo = await this.fetchSelfHostedUserInfo(serverUrl, token);
await this.saveTokenEverywhere(token);
@@ -995,13 +1226,19 @@ export class AuthService {
return userInfo;
}
private async fetchSelfHostedUserInfo(serverUrl: string, token: string): Promise<UserInfo> {
private async fetchSelfHostedUserInfo(
serverUrl: string,
token: string,
): Promise<UserInfo> {
try {
const response = await axios.get(`${serverUrl.replace(/\/+$/, "")}/api/v1/auth/me`, {
headers: {
Authorization: `Bearer ${token}`,
const response = await axios.get(
`${serverUrl.replace(/\/+$/, "")}/api/v1/auth/me`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
});
);
const data = response.data;
const user = data.user || data;
@@ -1011,7 +1248,10 @@ export class AuthService {
email: user.email || undefined,
};
} catch (error) {
console.error("[Desktop AuthService] Failed to fetch user info after SSO:", error);
console.error(
"[Desktop AuthService] Failed to fetch user info after SSO:",
error,
);
throw error;
}
}
@@ -1019,7 +1259,10 @@ export class AuthService {
/**
* Fetch user info from Supabase using access token
*/
private async fetchSupabaseUserInfo(authServerUrl: string, accessToken: string): Promise<UserInfo> {
private async fetchSupabaseUserInfo(
authServerUrl: string,
accessToken: string,
): Promise<UserInfo> {
try {
const userEndpoint = `${authServerUrl}/auth/v1/user`;
@@ -14,14 +14,20 @@ export async function getAuthTokenFromAnySource(): Promise<string | null> {
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;
}
}
@@ -25,7 +25,8 @@ class BackendHealthMonitor {
message:
status === "healthy"
? i18n.t("backendHealth.online", "Backend Online")
: (this.state.message ?? i18n.t("backendHealth.offline", "Backend Offline")),
: (this.state.message ??
i18n.t("backendHealth.offline", "Backend Offline")),
});
});
}
@@ -25,9 +25,12 @@ let lastBackendToast = 0;
export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
// Skip waiting if endpoint will be routed to SaaS backend
if (endpoint) {
const skipCheck = await operationRouter.shouldSkipBackendReadyCheck(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;
}
}
@@ -41,7 +44,10 @@ export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
// 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))]);
await Promise.race([
selfHostedServerMonitor.checkNow(),
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
]);
status = selfHostedServerMonitor.getSnapshot().status;
}
@@ -57,7 +57,13 @@ 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> {
@@ -70,7 +76,9 @@ export class ConnectionModeService {
return config.server_config;
}
subscribeToModeChanges(listener: (config: ConnectionConfig) => void): () => void {
subscribeToModeChanges(
listener: (config: ConnectionConfig) => void,
): () => void {
this.modeListeners.add(listener);
return () => {
this.modeListeners.delete(listener);
@@ -117,7 +125,11 @@ export class ConnectionModeService {
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;
}
}
@@ -148,7 +160,9 @@ export class ConnectionModeService {
// 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();
@@ -171,9 +185,15 @@ export class ConnectionModeService {
// For locked deployments, preserve server_config so the sign-in form can still
// show the correct server URL if the user wants to sign in later.
const isLocked = this.currentConfig?.lock_connection_mode ?? false;
const preservedServerConfig = isLocked ? (this.currentConfig?.server_config ?? null) : null;
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();
@@ -201,7 +221,9 @@ export class ConnectionModeService {
// 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.
@@ -217,33 +239,61 @@ export class ConnectionModeService {
* @returns Detailed test results with diagnostics and recommendations
*/
async testConnection(url: string): Promise<ConnectionTestResult> {
console.log(`[ConnectionModeService] 🔍 Starting comprehensive connection diagnostics for: ${url}`);
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION START ====================`);
console.log(
`[ConnectionModeService] 🔍 Starting comprehensive connection diagnostics for: ${url}`,
);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION START ====================`,
);
console.log(`[ConnectionModeService] System Information:`);
console.log(`[ConnectionModeService] - User Agent: ${navigator.userAgent}`);
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] - Max Touch Points: ${navigator.maxTouchPoints}`);
console.log(
`[ConnectionModeService] - Cookies Enabled: ${navigator.cookieEnabled}`,
);
console.log(
`[ConnectionModeService] - Hardware Concurrency: ${navigator.hardwareConcurrency || "unknown"} cores`,
);
console.log(
`[ConnectionModeService] - Max Touch Points: ${navigator.maxTouchPoints}`,
);
// Check for proxy environment variables
console.log(`[ConnectionModeService] Environment Check:`);
const envProxy = (window as any).process?.env?.HTTP_PROXY || (window as any).process?.env?.HTTPS_PROXY;
const envProxy =
(window as any).process?.env?.HTTP_PROXY ||
(window as any).process?.env?.HTTPS_PROXY;
if (envProxy) {
console.log(`[ConnectionModeService] - Proxy detected: ${envProxy}`);
} else {
console.log(`[ConnectionModeService] - No proxy environment variables detected`);
console.log(
`[ConnectionModeService] - No proxy environment variables detected`,
);
}
// Check if running in Tauri (v2 uses different detection)
console.log(`[ConnectionModeService] - Checking Tauri context...`);
console.log(`[ConnectionModeService] - window.__TAURI__ type: ${typeof (window as any).__TAURI__}`);
console.log(`[ConnectionModeService] - window.__TAURI_INTERNALS__ type: ${typeof (window as any).__TAURI_INTERNALS__}`);
console.log(`[ConnectionModeService] - window.location.href:`, window.location.href);
console.log(`[ConnectionModeService] - window.location.protocol:`, window.location.protocol);
console.log(
`[ConnectionModeService] - window.__TAURI__ type: ${typeof (window as any).__TAURI__}`,
);
console.log(
`[ConnectionModeService] - window.__TAURI_INTERNALS__ type: ${typeof (window as any).__TAURI_INTERNALS__}`,
);
console.log(
`[ConnectionModeService] - window.location.href:`,
window.location.href,
);
console.log(
`[ConnectionModeService] - window.location.protocol:`,
window.location.protocol,
);
// Tauri v2 detection: check for __TAURI_INTERNALS__ or tauri:// protocol
const isTauriV2 =
@@ -253,8 +303,12 @@ export class ConnectionModeService {
const isTauriV1 = typeof (window as any).__TAURI__ !== "undefined";
const isTauri = isTauriV1 || isTauriV2;
console.log(`[ConnectionModeService] - Running in Tauri v1: ${isTauriV1}`);
console.log(`[ConnectionModeService] - Running in Tauri v2: ${isTauriV2}`);
console.log(
`[ConnectionModeService] - Running in Tauri v1: ${isTauriV1}`,
);
console.log(
`[ConnectionModeService] - Running in Tauri v2: ${isTauriV2}`,
);
console.log(`[ConnectionModeService] - Running in Tauri: ${isTauri}`);
if (isTauri) {
@@ -263,7 +317,9 @@ export class ConnectionModeService {
console.log(`[ConnectionModeService] - Tauri v1 API:`, tauriApi);
}
if (isTauriV2) {
console.log(`[ConnectionModeService] - Tauri v2 detected via internals/protocol`);
console.log(
`[ConnectionModeService] - Tauri v2 detected via internals/protocol`,
);
const internals = (window as any).__TAURI_INTERNALS__;
console.log(`[ConnectionModeService] - Tauri internals:`, internals);
}
@@ -279,24 +335,43 @@ export class ConnectionModeService {
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] ================================================================`);
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)");
console.log(
`[ConnectionModeService] Stage 1: Testing HTTP (as specified in URL)`,
);
const stage1Result = await this.testHTTP(
healthUrl,
"Stage 1: HTTP (as specified)",
);
diagnostics.push(stage1Result);
if (stage1Result.success) {
console.log(`[ConnectionModeService] ✅ Connection successful with HTTP`);
console.log(
`[ConnectionModeService] ✅ Connection successful with HTTP`,
);
// Log success summary
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`,
);
console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`);
console.log(`[ConnectionModeService] Protocol: HTTP (as requested by user)`);
console.log(`[ConnectionModeService] Duration: ${stage1Result.duration}ms`);
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`);
console.log(
`[ConnectionModeService] Protocol: HTTP (as requested by user)`,
);
console.log(
`[ConnectionModeService] Duration: ${stage1Result.duration}ms`,
);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
);
return {
success: true,
@@ -307,7 +382,11 @@ 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 stage2Result = await this.testHTTPS(
httpsUrl,
"Stage 2: Trying HTTPS",
false,
);
diagnostics.push(stage2Result);
if (stage2Result.success) {
@@ -322,36 +401,72 @@ export class ConnectionModeService {
// Both failed, continue with more diagnostics below
} 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);
console.log(
`[ConnectionModeService] Stage 1: Testing HTTPS with full certificate validation`,
);
const stage1Result = await this.testHTTPS(
healthUrl,
"Stage 1: Standard HTTPS",
false,
);
diagnostics.push(stage1Result);
if (stage1Result.success) {
console.log(`[ConnectionModeService] ✅ Connection successful with standard HTTPS`);
console.log(
`[ConnectionModeService] ✅ Connection successful with standard HTTPS`,
);
// Log success summary
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`,
);
console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`);
console.log(`[ConnectionModeService] Protocol: HTTPS with valid certificate`);
console.log(`[ConnectionModeService] Duration: ${stage1Result.duration}ms`);
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`);
console.log(
`[ConnectionModeService] Protocol: HTTPS with valid certificate`,
);
console.log(
`[ConnectionModeService] Duration: ${stage1Result.duration}ms`,
);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
);
return { success: true, diagnostics };
}
// 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);
console.log(
`[ConnectionModeService] Stage 2: Testing HTTPS with certificate validation disabled`,
);
const stage2Result = await this.testHTTPS(
healthUrl,
"Stage 2: HTTPS (no cert validation)",
true,
);
diagnostics.push(stage2Result);
if (stage2Result.success) {
console.log(`[ConnectionModeService] ⚠️ Certificate issue detected - but connection works with bypass enabled`);
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL (with certificate bypass)`);
console.log(`[ConnectionModeService] Protocol: HTTPS with certificate validation disabled`);
console.log(`[ConnectionModeService] Duration: ${stage2Result.duration}ms`);
console.log(`[ConnectionModeService] Note: Server has missing intermediate certificate or invalid cert`);
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`);
console.log(
`[ConnectionModeService] ⚠️ Certificate issue detected - but connection works with bypass enabled`,
);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`,
);
console.log(
`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL (with certificate bypass)`,
);
console.log(
`[ConnectionModeService] Protocol: HTTPS with certificate validation disabled`,
);
console.log(
`[ConnectionModeService] Duration: ${stage2Result.duration}ms`,
);
console.log(
`[ConnectionModeService] Note: Server has missing intermediate certificate or invalid cert`,
);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
);
return {
success: true,
diagnostics,
@@ -359,9 +474,14 @@ export class ConnectionModeService {
}
// STAGE 3: Try HTTP instead (for local/internal servers)
console.log(`[ConnectionModeService] Stage 3: Testing HTTP instead of HTTPS`);
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 stage3Result = await this.testHTTP(
httpUrl,
"Stage 3: HTTP (unencrypted)",
);
diagnostics.push(stage3Result);
if (stage3Result.success) {
@@ -376,12 +496,16 @@ export class ConnectionModeService {
}
// STAGE 4: Test with longer timeout (diagnose slow connections)
console.log(`[ConnectionModeService] Stage 4: Testing with extended timeout (30s)`);
console.log(
`[ConnectionModeService] Stage 4: Testing with extended timeout (30s)`,
);
const stage4Result = await this.testWithLongTimeout(healthUrl);
diagnostics.push(stage4Result);
if (stage4Result.success) {
console.log(`[ConnectionModeService] ⚠️ Connection slow but eventually successful`);
console.log(
`[ConnectionModeService] ⚠️ Connection slow but eventually successful`,
);
return {
success: true,
diagnostics,
@@ -389,43 +513,69 @@ export class ConnectionModeService {
}
// STAGE 5A: Test external connectivity with standard endpoint
console.log(`[ConnectionModeService] Stage 5A: Testing external connectivity (google.com)`);
console.log(
`[ConnectionModeService] Stage 5A: Testing external connectivity (google.com)`,
);
const stage5aResult = await this.testStage5_ExternalConnectivity();
diagnostics.push(stage5aResult);
// STAGE 5B: Test with alternative endpoint (in case google is blocked)
console.log(`[ConnectionModeService] Stage 5B: Testing alternative endpoint (cloudflare.com)`);
console.log(
`[ConnectionModeService] Stage 5B: Testing alternative endpoint (cloudflare.com)`,
);
const stage5bResult = await this.testAlternativeEndpoint();
diagnostics.push(stage5bResult);
// STAGE 5C: Test with HTTP vs HTTPS for external endpoint
console.log(`[ConnectionModeService] Stage 5C: Testing HTTP external endpoint`);
console.log(
`[ConnectionModeService] Stage 5C: Testing HTTP external endpoint`,
);
const stage5cResult = await this.testHTTPExternal();
diagnostics.push(stage5cResult);
if (!stage5aResult.success && !stage5bResult.success && !stage5cResult.success) {
console.log(`[ConnectionModeService] ❌ No external connectivity - network/firewall issue`);
if (
!stage5aResult.success &&
!stage5bResult.success &&
!stage5cResult.success
) {
console.log(
`[ConnectionModeService] ❌ No external connectivity - network/firewall issue`,
);
return {
success: false,
error: "No internet connectivity detected. All network requests are failing.",
error:
"No internet connectivity detected. All network requests are failing.",
errorCode: "NETWORK_BLOCKED",
diagnostics,
};
}
// If some external endpoints work but not the target, it's more specific
if (stage5aResult.success || stage5bResult.success || stage5cResult.success) {
console.log(`[ConnectionModeService] ✅ External connectivity confirmed - issue is specific to target server`);
if (
stage5aResult.success ||
stage5bResult.success ||
stage5cResult.success
) {
console.log(
`[ConnectionModeService] ✅ External connectivity confirmed - issue is specific to target server`,
);
}
// STAGE 6: Test DNS resolution for the target server
console.log(`[ConnectionModeService] Stage 6: Testing DNS resolution for target server`);
console.log(
`[ConnectionModeService] Stage 6: Testing DNS resolution for target server`,
);
const urlObj = new URL(url);
const stage6Result = await this.testStage6_DNSResolution(urlObj.hostname);
diagnostics.push(stage6Result);
if (!stage6Result.success && stage6Result.message.includes("DNS lookup failed")) {
console.log(`[ConnectionModeService] ❌ DNS resolution failed for target server`);
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}`,
@@ -440,7 +590,9 @@ export class ConnectionModeService {
diagnostics.push(stage7Result);
if (stage7Result.success) {
console.log(`[ConnectionModeService] ⚠️ HEAD method works but GET doesn't - unusual server behavior`);
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.",
@@ -450,40 +602,65 @@ export class ConnectionModeService {
}
// STAGE 8: Try with modified User-Agent
console.log(`[ConnectionModeService] Stage 8: Testing with browser User-Agent`);
console.log(
`[ConnectionModeService] Stage 8: Testing with browser User-Agent`,
);
const stage8Result = await this.testWithBrowserUserAgent(healthUrl);
diagnostics.push(stage8Result);
if (stage8Result.success) {
console.log(`[ConnectionModeService] ⚠️ Works with browser User-Agent - server may be blocking desktop apps`);
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.",
error:
"Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.",
errorCode: "USER_AGENT_BLOCKED",
diagnostics,
};
}
// STAGE 9: Final analysis - server-specific issue
console.log(`[ConnectionModeService] ❌ Server unreachable - all diagnostic tests failed`);
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)
.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] Average failure time: ${avgDuration.toFixed(0)}ms`);
console.log(`[ConnectionModeService] ---------------------------------------------------------------`);
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] Average failure time: ${avgDuration.toFixed(0)}ms`,
);
console.log(
`[ConnectionModeService] ---------------------------------------------------------------`,
);
diagnostics.forEach((diag) => {
const icon = diag.success ? "✅" : "❌";
console.log(`[ConnectionModeService] ${icon} ${diag.stage}: ${diag.message} (${diag.duration}ms)`);
console.log(
`[ConnectionModeService] ${icon} ${diag.stage}: ${diag.message} (${diag.duration}ms)`,
);
});
console.log(`[ConnectionModeService] ================================================================`);
console.log(
`[ConnectionModeService] ================================================================`,
);
console.log(`[ConnectionModeService] Error Code: SERVER_UNREACHABLE`);
// Log timing-based analysis
@@ -496,14 +673,19 @@ export class ConnectionModeService {
`[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`);
console.log(
`[ConnectionModeService] Analysis: Server may be down, blocking connections, or behind a firewall`,
);
}
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`);
console.log(
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
);
return {
success: false,
error: "Cannot connect to server. Internet works but this specific server is unreachable.",
error:
"Cannot connect to server. Internet works but this specific server is unreachable.",
errorCode: "SERVER_UNREACHABLE",
diagnostics,
};
@@ -527,11 +709,19 @@ export class ConnectionModeService {
}
}
private async testHTTPS(url: string, stageName: string, disableCertValidation: boolean): Promise<DiagnosticResult> {
private async testHTTPS(
url: string,
stageName: string,
disableCertValidation: boolean,
): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
console.log(`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? "DISABLED" : "ENABLED"}`);
console.log(
`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`,
);
console.log(
`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? "DISABLED" : "ENABLED"}`,
);
const fetchOptions: any = {
method: "GET",
@@ -545,11 +735,16 @@ export class ConnectionModeService {
};
}
console.log(`[ConnectionModeService] - Fetch options:`, JSON.stringify(fetchOptions));
console.log(
`[ConnectionModeService] - Fetch options:`,
JSON.stringify(fetchOptions),
);
const response = await fetch(url, fetchOptions);
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok) {
return {
@@ -572,13 +767,22 @@ export class ConnectionModeService {
const duration = Date.now() - startTime;
// Enhanced error logging
console.error(`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`);
console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`);
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[ConnectionModeService] ${stageName}: Request failed (${duration}ms)`,
);
console.error(
`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`,
);
console.error(
`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`,
);
// Log full error object structure for debugging
if (error && typeof error === "object") {
console.error(`[ConnectionModeService] - Error keys:`, Object.keys(error));
console.error(
`[ConnectionModeService] - Error keys:`,
Object.keys(error),
);
console.error(
`[ConnectionModeService] - Error object:`,
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
@@ -596,10 +800,14 @@ export class ConnectionModeService {
errorLower.includes("peer is incompatible") ||
errorLower.includes("protocol version") ||
errorLower.includes("peerincompatible") ||
(errorLower.includes("handshake") && (errorLower.includes("tls") || errorLower.includes("ssl")))
(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") ||
@@ -608,17 +816,29 @@ export class ConnectionModeService {
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`;
}
console.error(`[ConnectionModeService] - Categorized as: ${detailedMessage}`);
console.error(
`[ConnectionModeService] - Categorized as: ${detailedMessage}`,
);
return {
stage: stageName,
@@ -629,10 +849,15 @@ export class ConnectionModeService {
}
}
private async testHTTP(url: string, stageName: string): Promise<DiagnosticResult> {
private async testHTTP(
url: string,
stageName: string,
): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
console.log(
`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`,
);
const response = await fetch(url, {
method: "GET",
@@ -640,7 +865,9 @@ export class ConnectionModeService {
});
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok) {
return {
@@ -661,9 +888,15 @@ export class ConnectionModeService {
const duration = Date.now() - startTime;
// Enhanced error logging
console.error(`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`);
console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`);
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[ConnectionModeService] ${stageName}: Request failed (${duration}ms)`,
);
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(
@@ -683,7 +916,9 @@ export class ConnectionModeService {
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
}
console.error(`[ConnectionModeService] - Categorized as: ${detailedMessage}`);
console.error(
`[ConnectionModeService] - Categorized as: ${detailedMessage}`,
);
return {
stage: stageName,
@@ -733,7 +968,9 @@ export class ConnectionModeService {
private async testStage5_ExternalConnectivity(): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🌐 Stage 5A: Testing external connectivity (google.com)`);
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", {
@@ -763,7 +1000,9 @@ export class ConnectionModeService {
};
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[ConnectionModeService] ❌ Stage 5A: External connectivity test failed (${duration}ms)`);
console.error(
`[ConnectionModeService] ❌ Stage 5A: External connectivity test failed (${duration}ms)`,
);
console.error(`[ConnectionModeService] - Error:`, error);
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -779,7 +1018,9 @@ export class ConnectionModeService {
private async testAlternativeEndpoint(): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🌐 Stage 5B: Testing alternative endpoint (cloudflare.com)`);
console.log(
`[ConnectionModeService] 🌐 Stage 5B: Testing alternative endpoint (cloudflare.com)`,
);
const response = await fetch("https://1.1.1.1", {
method: "HEAD",
@@ -791,7 +1032,12 @@ export class ConnectionModeService {
`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok || response.status === 301 || response.status === 302 || response.status === 403) {
if (
response.ok ||
response.status === 301 ||
response.status === 302 ||
response.status === 403
) {
return {
stage: "Stage 5B: External (cloudflare)",
success: true,
@@ -808,7 +1054,9 @@ export class ConnectionModeService {
};
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[ConnectionModeService] ❌ Stage 5B: Alternative endpoint failed (${duration}ms)`);
console.error(
`[ConnectionModeService] ❌ Stage 5B: Alternative endpoint failed (${duration}ms)`,
);
console.error(`[ConnectionModeService] - Error:`, error);
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -824,7 +1072,9 @@ export class ConnectionModeService {
private async testHTTPExternal(): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🌐 Stage 5C: Testing HTTP external endpoint (httpbin.org)`);
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", {
@@ -833,7 +1083,9 @@ export class ConnectionModeService {
});
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ Stage 5C: HTTP endpoint success - HTTP ${response.status} (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ Stage 5C: HTTP endpoint success - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok) {
return {
@@ -852,7 +1104,9 @@ export class ConnectionModeService {
};
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[ConnectionModeService] ❌ Stage 5C: HTTP external failed (${duration}ms)`);
console.error(
`[ConnectionModeService] ❌ Stage 5C: HTTP external failed (${duration}ms)`,
);
console.error(`[ConnectionModeService] - Error:`, error);
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -865,10 +1119,14 @@ export class ConnectionModeService {
}
}
private async testStage6_DNSResolution(hostname: string): Promise<DiagnosticResult> {
private async testStage6_DNSResolution(
hostname: string,
): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🔍 Stage 6: Testing DNS resolution for ${hostname}`);
console.log(
`[ConnectionModeService] 🔍 Stage 6: Testing DNS resolution for ${hostname}`,
);
// Try to resolve DNS by making a HEAD request to the base domain
// If DNS fails, we'll get an immediate error
@@ -879,7 +1137,9 @@ export class ConnectionModeService {
});
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ Stage 6: DNS resolved successfully (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ Stage 6: DNS resolved successfully (${duration}ms)`,
);
return {
stage: "Stage 6: DNS resolution",
@@ -892,11 +1152,17 @@ export class ConnectionModeService {
const errorMsg = error instanceof Error ? error.message : String(error);
const errorLower = errorMsg.toLowerCase();
console.error(`[ConnectionModeService] ❌ Stage 6: DNS test failed (${duration}ms)`);
console.error(
`[ConnectionModeService] ❌ Stage 6: DNS test failed (${duration}ms)`,
);
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",
success: false,
@@ -918,7 +1184,9 @@ export class ConnectionModeService {
private async testWithHEADMethod(url: string): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🔗 Stage 7: Testing with HEAD method`);
console.log(
`[ConnectionModeService] 🔗 Stage 7: Testing with HEAD method`,
);
const response = await fetch(url, {
method: "HEAD",
@@ -926,7 +1194,9 @@ export class ConnectionModeService {
});
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ Stage 7: HEAD method success - HTTP ${response.status} (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ Stage 7: HEAD method success - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok) {
return {
@@ -945,7 +1215,9 @@ export class ConnectionModeService {
};
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[ConnectionModeService] ❌ Stage 7: HEAD method failed (${duration}ms)`);
console.error(
`[ConnectionModeService] ❌ Stage 7: HEAD method failed (${duration}ms)`,
);
const errorMsg = error instanceof Error ? error.message : String(error);
return {
@@ -957,10 +1229,14 @@ export class ConnectionModeService {
}
}
private async testWithBrowserUserAgent(url: string): Promise<DiagnosticResult> {
private async testWithBrowserUserAgent(
url: string,
): Promise<DiagnosticResult> {
const startTime = Date.now();
try {
console.log(`[ConnectionModeService] 🔗 Stage 8: Testing with browser User-Agent`);
console.log(
`[ConnectionModeService] 🔗 Stage 8: Testing with browser User-Agent`,
);
// Try with a standard browser User-Agent instead of Tauri's default
const response = await fetch(url, {
@@ -973,7 +1249,9 @@ export class ConnectionModeService {
});
const duration = Date.now() - startTime;
console.log(`[ConnectionModeService] ✅ Stage 8: Browser User-Agent success - HTTP ${response.status} (${duration}ms)`);
console.log(
`[ConnectionModeService] ✅ Stage 8: Browser User-Agent success - HTTP ${response.status} (${duration}ms)`,
);
if (response.ok) {
return {
@@ -992,7 +1270,9 @@ export class ConnectionModeService {
};
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[ConnectionModeService] ❌ Stage 8: Browser User-Agent failed (${duration}ms)`);
console.error(
`[ConnectionModeService] ❌ Stage 8: Browser User-Agent failed (${duration}ms)`,
);
const errorMsg = error instanceof Error ? error.message : String(error);
return {
@@ -22,7 +22,9 @@ export const defaultAppService = {
* Set or prompt to set Stirling PDF as default PDF handler
* Returns a status string indicating what happened
*/
async setAsDefaultPdfHandler(): Promise<"set_successfully" | "opened_dialog" | "error"> {
async setAsDefaultPdfHandler(): Promise<
"set_successfully" | "opened_dialog" | "error"
> {
try {
const result = await invoke<string>("set_as_default_pdf_handler");
return result as "set_successfully" | "opened_dialog";
@@ -37,7 +39,9 @@ export const defaultAppService = {
*/
hasUserDismissedPrompt(): boolean {
try {
const dismissed = localStorage.getItem("stirlingpdf_default_app_prompt_dismissed");
const dismissed = localStorage.getItem(
"stirlingpdf_default_app_prompt_dismissed",
);
return dismissed === "true";
} catch {
return false;
@@ -49,7 +53,10 @@ export const defaultAppService = {
*/
setPromptDismissed(dismissed: boolean): void {
try {
localStorage.setItem("stirlingpdf_default_app_prompt_dismissed", dismissed ? "true" : "false");
localStorage.setItem(
"stirlingpdf_default_app_prompt_dismissed",
dismissed ? "true" : "false",
);
} catch (error) {
console.error("[DefaultApp] Failed to save prompt preference:", error);
}
@@ -1,6 +1,10 @@
import { isTauri } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
import i18n from "@app/i18n";
const APP_TITLE = "Stirling-PDF";
@@ -23,8 +27,13 @@ async function shouldShowBackgroundNotification(): Promise<boolean> {
}
}
export async function notifyPdfProcessingComplete(fileCount: number): Promise<void> {
console.log("[DesktopNotification] notifyPdfProcessingComplete called with fileCount:", fileCount);
export async function notifyPdfProcessingComplete(
fileCount: number,
): Promise<void> {
console.log(
"[DesktopNotification] notifyPdfProcessingComplete called with fileCount:",
fileCount,
);
if (!isTauri() || fileCount <= 0) {
console.log("[DesktopNotification] Skipped: !isTauri() or fileCount <= 0");
@@ -51,14 +60,18 @@ export async function notifyPdfProcessingComplete(fileCount: number): Promise<vo
}
if (!permissionGranted) {
console.log("[DesktopNotification] Permission not granted, skipping notification");
console.log(
"[DesktopNotification] Permission not granted, skipping notification",
);
return;
}
const body =
fileCount === 1
? i18n.t("processingComplete", "Your file is ready.")
: i18n.t("processingCompleteMultiple", "{{count}} files are ready.", { count: fileCount });
: i18n.t("processingCompleteMultiple", "{{count}} files are ready.", {
count: fileCount,
});
console.log("[DesktopNotification] Sending notification:", body);
await sendNotification({
title: APP_TITLE,
@@ -1,9 +1,17 @@
import type { DownloadRequest, DownloadResult } from "@core/services/downloadService";
import { saveToLocalPath, showSaveDialog } from "@app/services/localFileSaveService";
import type {
DownloadRequest,
DownloadResult,
} from "@core/services/downloadService";
import {
saveToLocalPath,
showSaveDialog,
} from "@app/services/localFileSaveService";
export type { DownloadRequest, DownloadResult };
export async function downloadFile(request: DownloadRequest): Promise<DownloadResult> {
export async function downloadFile(
request: DownloadRequest,
): Promise<DownloadResult> {
if (request.localPath) {
const result = await saveToLocalPath(request.data, request.localPath);
if (!result.success) {
@@ -25,7 +33,11 @@ export async function downloadFile(request: DownloadRequest): Promise<DownloadRe
return { savedPath: savePath };
}
export async function downloadFromUrl(url: string, filename: string, localPath?: string): Promise<DownloadResult> {
export async function downloadFromUrl(
url: string,
filename: string,
localPath?: string,
): Promise<DownloadResult> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed (${response.status})`);
@@ -22,7 +22,10 @@ export class EndpointAvailabilityService {
* @param backendUrl - The URL for the backend
* @returns Promise<boolean> - true if supported locally, false otherwise
*/
async isEndpointSupportedLocally(endpoint: string, backendUrl: string | null): Promise<boolean> {
async isEndpointSupportedLocally(
endpoint: string,
backendUrl: string | null,
): Promise<boolean> {
// Check cache first
const cached = this.localCache.get(endpoint);
const expiry = this.localCacheExpiry.get(endpoint);
@@ -48,7 +51,9 @@ export class EndpointAvailabilityService {
});
if (!response.ok) {
console.warn(`[endpointAvailabilityService] Failed to check local endpoint availability: ${response.status}`);
console.warn(
`[endpointAvailabilityService] Failed to check local endpoint availability: ${response.status}`,
);
return false;
}
@@ -61,7 +66,10 @@ export class EndpointAvailabilityService {
return available;
} catch (error) {
console.error(`[endpointAvailabilityService] Error checking local endpoint availability:`, error);
console.error(
`[endpointAvailabilityService] Error checking local endpoint availability:`,
error,
);
return false; // Assume not supported on error
}
}
@@ -99,7 +107,9 @@ export class EndpointAvailabilityService {
});
if (!response.ok) {
console.warn(`[endpointAvailabilityService] Failed to check SaaS endpoint availability: ${response.status}`);
console.warn(
`[endpointAvailabilityService] Failed to check SaaS endpoint availability: ${response.status}`,
);
return false;
}
@@ -112,7 +122,10 @@ export class EndpointAvailabilityService {
return available;
} catch (error) {
console.error(`[endpointAvailabilityService] Error checking SaaS endpoint availability:`, error);
console.error(
`[endpointAvailabilityService] Error checking SaaS endpoint availability:`,
error,
);
return false; // Assume not supported on error
}
}
@@ -138,7 +151,9 @@ export class EndpointAvailabilityService {
this.localCache.forEach((available, endpoint) => {
const expiry = this.localCacheExpiry.get(endpoint);
const expiresIn = expiry ? Math.max(0, expiry - Date.now()) : 0;
console.log(`${endpoint}: ${available} (expires in ${Math.round(expiresIn / 1000)}s)`);
console.log(
`${endpoint}: ${available} (expires in ${Math.round(expiresIn / 1000)}s)`,
);
});
console.groupEnd();
@@ -146,7 +161,9 @@ export class EndpointAvailabilityService {
this.saasCache.forEach((available, endpoint) => {
const expiry = this.saasCacheExpiry.get(endpoint);
const expiresIn = expiry ? Math.max(0, expiry - Date.now()) : 0;
console.log(`${endpoint}: ${available} (expires in ${Math.round(expiresIn / 1000)}s)`);
console.log(
`${endpoint}: ${available} (expires in ${Math.round(expiresIn / 1000)}s)`,
);
});
console.groupEnd();
@@ -160,7 +177,10 @@ export class EndpointAvailabilityService {
* @param endpoints - Array of endpoint paths to check
* @param backendUrl - The URL of the backend
*/
async preloadEndpoints(endpoints: string[], backendUrl: string | null): Promise<void> {
async preloadEndpoints(
endpoints: string[],
backendUrl: string | null,
): Promise<void> {
if (!backendUrl || endpoints.length === 0) {
return;
}
@@ -186,10 +206,15 @@ export class EndpointAvailabilityService {
this.localCacheExpiry.set(endpoint, now + this.CACHE_DURATION);
});
} else {
console.warn(`[endpointAvailabilityService] Failed to preload endpoints: ${response.status}`);
console.warn(
`[endpointAvailabilityService] Failed to preload endpoints: ${response.status}`,
);
}
} catch (error) {
console.error("[endpointAvailabilityService] Error preloading endpoints:", error);
console.error(
"[endpointAvailabilityService] Error preloading endpoints:",
error,
);
}
}
@@ -229,28 +254,46 @@ export class EndpointAvailabilityService {
* Get cache statistics (useful for debugging)
*/
getCacheStats(): {
local: { size: number; entries: Array<{ endpoint: string; available: boolean; expiresIn: number }> };
saas: { size: number; entries: Array<{ endpoint: string; available: boolean; expiresIn: number }> };
local: {
size: number;
entries: Array<{
endpoint: string;
available: boolean;
expiresIn: number;
}>;
};
saas: {
size: number;
entries: Array<{
endpoint: string;
available: boolean;
expiresIn: number;
}>;
};
} {
const now = Date.now();
const localEntries = Array.from(this.localCache.entries()).map(([endpoint, available]) => {
const expiry = this.localCacheExpiry.get(endpoint) ?? 0;
return {
endpoint,
available,
expiresIn: Math.max(0, expiry - now),
};
});
const localEntries = Array.from(this.localCache.entries()).map(
([endpoint, available]) => {
const expiry = this.localCacheExpiry.get(endpoint) ?? 0;
return {
endpoint,
available,
expiresIn: Math.max(0, expiry - now),
};
},
);
const saasEntries = Array.from(this.saasCache.entries()).map(([endpoint, available]) => {
const expiry = this.saasCacheExpiry.get(endpoint) ?? 0;
return {
endpoint,
available,
expiresIn: Math.max(0, expiry - now),
};
});
const saasEntries = Array.from(this.saasCache.entries()).map(
([endpoint, available]) => {
const expiry = this.saasCacheExpiry.get(endpoint) ?? 0;
return {
endpoint,
available,
expiresIn: Math.max(0, expiry - now),
};
},
);
return {
local: {
@@ -1,5 +1,8 @@
// Desktop implementation - Tauri native file dialogs
import type { FileWithPath, FileDialogOptions } from "@core/services/fileDialogService";
import type {
FileWithPath,
FileDialogOptions,
} from "@core/services/fileDialogService";
import { createQuickKey } from "@app/types/fileContext";
import { getDocumentFileDialogFilter } from "@app/utils/fileDialogUtils";
@@ -8,7 +11,9 @@ export type { FileWithPath, FileDialogOptions };
/**
* Open native file dialog and read selected files (Desktop/Tauri only)
*/
export async function openFileDialog(options?: FileDialogOptions): Promise<FileWithPath[]> {
export async function openFileDialog(
options?: FileDialogOptions,
): Promise<FileWithPath[]> {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readFile } = await import("@tauri-apps/plugin-fs");
@@ -24,7 +29,9 @@ export async function openFileDialog(options?: FileDialogOptions): Promise<FileW
return [];
}
const paths = Array.isArray(selectedPaths) ? selectedPaths : [selectedPaths];
const paths = Array.isArray(selectedPaths)
? selectedPaths
: [selectedPaths];
console.log(`[FileDialog] Selected ${paths.length} file(s):`, paths);
const filesWithPaths: FileWithPath[] = [];
@@ -38,7 +45,9 @@ export async function openFileDialog(options?: FileDialogOptions): Promise<FileW
type: fileName.endsWith(".pdf") ? "application/pdf" : undefined,
});
const quickKey = createQuickKey(file);
console.log(`[FileDialog] Created File: ${fileName}, quickKey: ${quickKey}`);
console.log(
`[FileDialog] Created File: ${fileName}, quickKey: ${quickKey}`,
);
filesWithPaths.push({
file,
@@ -2,7 +2,9 @@ import { invoke, isTauri } from "@tauri-apps/api/core";
export interface FileOpenService {
getOpenedFiles(): Promise<string[]>;
readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
readFileAsArrayBuffer(
filePath: string,
): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
clearOpenedFiles(): Promise<void>;
onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function
}
@@ -20,7 +22,9 @@ class TauriFileOpenService implements FileOpenService {
}
}
async readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
async readFileAsArrayBuffer(
filePath: string,
): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
try {
const { readFile } = await import("@tauri-apps/plugin-fs");
@@ -29,7 +33,10 @@ class TauriFileOpenService implements FileOpenService {
return {
fileName,
arrayBuffer: fileData.buffer.slice(fileData.byteOffset, fileData.byteOffset + fileData.byteLength),
arrayBuffer: fileData.buffer.slice(
fileData.byteOffset,
fileData.byteOffset + fileData.byteLength,
),
};
} catch (error) {
console.error("Failed to read file:", error);
@@ -115,7 +122,9 @@ class WebFileOpenService implements FileOpenService {
return [];
}
async readFileAsArrayBuffer(_filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
async readFileAsArrayBuffer(
_filePath: string,
): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
// In web mode, cannot read arbitrary file paths
return null;
}
@@ -134,4 +143,6 @@ class WebFileOpenService implements FileOpenService {
}
// Export the appropriate service based on environment
export const fileOpenService: FileOpenService = isTauri() ? new TauriFileOpenService() : new WebFileOpenService();
export const fileOpenService: FileOpenService = isTauri()
? new TauriFileOpenService()
: new WebFileOpenService();
@@ -1,4 +1,7 @@
import type { SaveResult, MultiFileSaveResult } from "@core/services/localFileSaveService";
import type {
SaveResult,
MultiFileSaveResult,
} from "@core/services/localFileSaveService";
export type { SaveResult, MultiFileSaveResult };
/**
@@ -8,7 +11,10 @@ export type { SaveResult, MultiFileSaveResult };
* @param filePath - Absolute path to save to
* @returns Result indicating success or failure with error message
*/
export async function saveToLocalPath(data: Blob | File, filePath: string): Promise<SaveResult> {
export async function saveToLocalPath(
data: Blob | File,
filePath: string,
): Promise<SaveResult> {
try {
const { writeFile } = await import("@tauri-apps/plugin-fs");
const arrayBuffer = await data.arrayBuffer();
@@ -28,7 +34,10 @@ export async function saveToLocalPath(data: Blob | File, filePath: string): Prom
* @param defaultDirectory - Optional default directory
* @returns Selected file path or null if cancelled
*/
export async function showSaveDialog(defaultFilename: string, defaultDirectory?: string): Promise<string | null> {
export async function showSaveDialog(
defaultFilename: string,
defaultDirectory?: string,
): Promise<string | null> {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
@@ -38,7 +47,9 @@ export async function showSaveDialog(defaultFilename: string, defaultDirectory?:
const filters = ext ? [{ name: ext.toUpperCase(), extensions: [ext] }] : [];
const selectedPath = await save({
defaultPath: defaultDirectory ? `${defaultDirectory}/${defaultFilename}` : defaultFilename,
defaultPath: defaultDirectory
? `${defaultDirectory}/${defaultFilename}`
: defaultFilename,
filters,
title: "Save As",
});
@@ -85,7 +96,8 @@ export async function saveMultipleFilesWithPrompt(
for (const file of files) {
try {
const fileName = file instanceof File ? file.name : `output_${savedCount + 1}.pdf`;
const fileName =
file instanceof File ? file.name : `output_${savedCount + 1}.pdf`;
const filePath = await join(selectedFolder as string, fileName);
const arrayBuffer = await file.arrayBuffer();
await writeFile(filePath, new Uint8Array(arrayBuffer));
@@ -25,7 +25,11 @@ async function resolvePdfSource(file?: File | Blob, url?: string | null) {
return response.blob();
}
export async function printPdfNatively(file?: File | Blob, url?: string | null, fileName = "document.pdf") {
export async function printPdfNatively(
file?: File | Blob,
url?: string | null,
fileName = "document.pdf",
) {
const source = await resolvePdfSource(file, url);
if (!source) {
throw new Error("No PDF source available for native print");
@@ -34,7 +38,10 @@ export async function printPdfNatively(file?: File | Blob, url?: string | null,
const { tempDir, join } = await import("@tauri-apps/api/path");
const { remove, writeFile } = await import("@tauri-apps/plugin-fs");
const tempPath = await join(await tempDir(), `stirling-print-${crypto.randomUUID()}-${sanitizeFileName(fileName)}`);
const tempPath = await join(
await tempDir(),
`stirling-print-${crypto.randomUUID()}-${sanitizeFileName(fileName)}`,
);
await writeFile(tempPath, new Uint8Array(await source.arrayBuffer()));
@@ -1,10 +1,16 @@
import type { FileId } from "@app/types/fileContext";
import type { OperationSaveContext } from "@core/services/operationResultsSaveService";
import { downloadFile, downloadFromUrl, DownloadResult } from "@app/services/downloadService";
import {
downloadFile,
downloadFromUrl,
DownloadResult,
} from "@app/services/downloadService";
export type { OperationSaveContext };
export async function saveOperationResults(context: OperationSaveContext): Promise<DownloadResult | null> {
export async function saveOperationResults(
context: OperationSaveContext,
): Promise<DownloadResult | null> {
if (!context.downloadUrl) return null;
if (context.outputFileIds && context.outputFileIds.length > 0) {
@@ -4,7 +4,10 @@ import { tauriBackendService } from "@app/services/tauriBackendService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
import { CONVERSION_ENDPOINTS, ENDPOINT_NAMES } from "@app/constants/convertConstants";
import {
CONVERSION_ENDPOINTS,
ENDPOINT_NAMES,
} from "@app/constants/convertConstants";
export type ExecutionTarget = "local" | "remote";
@@ -105,7 +108,9 @@ export class OperationRouter {
}
}
// Fallback to pattern-based extraction if not found in constants
const convertMatch = endpoint.match(/^\/api\/v1\/convert\/([^/]+)\/([^/]+)$/);
const convertMatch = endpoint.match(
/^\/api\/v1\/convert\/([^/]+)\/([^/]+)$/,
);
if (convertMatch) {
const [, from, to] = convertMatch;
return `${from}-to-${to}`;
@@ -113,7 +118,9 @@ export class OperationRouter {
}
// Tool operation endpoints: /api/v1/{category}/{endpoint-name}
const toolMatch = endpoint.match(/^\/api\/v1\/(?:general|misc|security|filter|multi-tool)\/(.+)$/);
const toolMatch = endpoint.match(
/^\/api\/v1\/(?:general|misc|security|filter|multi-tool)\/(.+)$/,
);
if (toolMatch) {
return toolMatch[1];
}
@@ -137,10 +144,18 @@ export class OperationRouter {
const endpointName = this.extractEndpointName(operation);
const backendUrl = tauriBackendService.getBackendUrl();
if (backendUrl) {
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(endpointName, backendUrl);
const supportedLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
backendUrl,
);
if (!supportedLocally) {
// Open the connection settings so the user can sign in
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: "connectionMode" } }));
window.dispatchEvent(
new CustomEvent("appConfig:navigate", {
detail: { key: "connectionMode" },
}),
);
throw new Error(
i18n.t(
"localMode.toolUnavailable",
@@ -152,7 +167,9 @@ export class OperationRouter {
}
const backendUrl = tauriBackendService.getBackendUrl();
if (!backendUrl) {
throw new Error("Backend URL not available - backend may still be starting");
throw new Error(
"Backend URL not available - backend may still be starting",
);
}
return backendUrl.replace(/\/$/, "");
}
@@ -162,7 +179,9 @@ export class OperationRouter {
if (!STIRLING_SAAS_BACKEND_API_URL) {
throw new Error("VITE_SAAS_BACKEND_API_URL not configured");
}
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (team endpoint)`);
console.debug(
`[operationRouter] Routing ${operation} to SaaS backend (team endpoint)`,
);
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, "");
}
@@ -170,7 +189,9 @@ export class OperationRouter {
if (mode === "saas" && operation && this.isToolEndpoint(operation)) {
// Extract endpoint name for capability check (e.g., "/api/v1/misc/repair" -> "repair")
const endpointToCheck = this.extractEndpointName(operation);
console.debug(`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`);
console.debug(
`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`,
);
const backendUrl = tauriBackendService.getBackendUrl();
const backendHealthy = tauriBackendService.isOnline;
@@ -179,17 +200,30 @@ export class OperationRouter {
// capability check and fall through to local routing — the backend-readiness check
// in the Axios interceptor will block non-GET requests until the backend is healthy.
if (backendUrl && backendHealthy) {
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(endpointToCheck, backendUrl);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
const supportedLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
backendUrl,
);
console.debug(
`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`,
);
if (!supportedLocally) {
// Local backend doesn't support this - check if SaaS supports it
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
const supportedOnSaaS =
await endpointAvailabilityService.isEndpointSupportedOnSaaS(
endpointToCheck,
);
console.debug(
`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`,
);
if (!supportedOnSaaS) {
// Neither local nor SaaS support this - throw error
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
console.error(
`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`,
);
throw new Error(
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`,
);
@@ -197,7 +231,9 @@ export class OperationRouter {
// SaaS supports it - route to SaaS backend
if (!STIRLING_SAAS_BACKEND_API_URL) {
console.error("[operationRouter] VITE_SAAS_BACKEND_API_URL not configured");
console.error(
"[operationRouter] VITE_SAAS_BACKEND_API_URL not configured",
);
throw new Error(
"Cloud processing is required for this tool but VITE_SAAS_BACKEND_API_URL is not configured. " +
"Please check your environment configuration.",
@@ -210,7 +246,9 @@ export class OperationRouter {
}
// Supported locally - continue with local backend
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
console.debug(
`[operationRouter] Routing ${operation} to local backend (supported locally)`,
);
}
}
@@ -222,9 +260,15 @@ export class OperationRouter {
const endpointName = this.extractEndpointName(operation);
const localUrl = tauriBackendService.getBackendUrl();
if (localUrl) {
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(endpointName, localUrl);
const supportedLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
localUrl,
);
if (supportedLocally) {
console.debug(`[operationRouter] Self-hosted server offline, routing ${operation} to local backend`);
console.debug(
`[operationRouter] Self-hosted server offline, routing ${operation} to local backend`,
);
return localUrl.replace(/\/$/, "");
}
}
@@ -245,7 +289,9 @@ export class OperationRouter {
// Use dynamically assigned port from backend service
const backendUrl = tauriBackendService.getBackendUrl();
if (!backendUrl) {
throw new Error("Backend URL not available - backend may still be starting");
throw new Error(
"Backend URL not available - backend may still be starting",
);
}
// Strip trailing slash to avoid double slashes in URLs
return backendUrl.replace(/\/$/, "");
@@ -298,7 +344,11 @@ export class OperationRouter {
// Backend not ready — don't skip the readiness check; let it gate the request.
if (!backendUrl || !tauriBackendService.isOnline) return false;
const endpointToCheck = this.extractEndpointName(endpoint);
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(endpointToCheck, backendUrl);
const supportedLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
backendUrl,
);
return !supportedLocally; // Skip check if not supported locally
}
@@ -324,10 +374,11 @@ export class OperationRouter {
if (this.isToolEndpoint(endpoint)) {
// For UI data endpoints, extract the endpoint name
const endpointToCheck = this.extractEndpointName(endpoint);
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
tauriBackendService.getBackendUrl(),
);
const supportedLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
tauriBackendService.getBackendUrl(),
);
return !supportedLocally;
}
@@ -3,8 +3,16 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { supabase } from "@app/auth/supabase";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL, SUPABASE_KEY } from "@app/constants/connection";
import type { TierLevel, SubscriptionStatus, StripePlanId } from "@app/types/billing";
import {
STIRLING_SAAS_URL,
STIRLING_SAAS_BACKEND_API_URL,
SUPABASE_KEY,
} from "@app/constants/connection";
import type {
TierLevel,
SubscriptionStatus,
StripePlanId,
} from "@app/types/billing";
import { getCurrencySymbol } from "@app/config/billing";
/**
@@ -66,7 +74,10 @@ export class SaasBillingService {
const isAuthenticated = await authService.isAuthenticated();
return mode === "saas" && isAuthenticated;
} catch (error) {
console.error("[Desktop Billing] Failed to check billing availability:", error);
console.error(
"[Desktop Billing] Failed to check billing availability:",
error,
);
return false;
}
}
@@ -105,7 +116,9 @@ export class SaasBillingService {
if (!response.ok) {
const errorText = await response.text();
console.error("[Desktop Billing] RPC error response:", errorText);
throw new Error(`RPC call failed: ${response.status} ${response.statusText}`);
throw new Error(
`RPC call failed: ${response.status} ${response.statusText}`,
);
}
// RPC may return an array or a single object — normalise to array then take first element
@@ -132,15 +145,16 @@ export class SaasBillingService {
if (isPro) {
// Fetch usage details
try {
const { data: usageData, error: usageError } = await supabase.functions.invoke<{
subscription: BillingStatus["subscription"];
meterUsage: BillingStatus["meterUsage"];
}>("get-usage-billing", {
headers: {
Authorization: `Bearer ${token}`,
},
body: {},
});
const { data: usageData, error: usageError } =
await supabase.functions.invoke<{
subscription: BillingStatus["subscription"];
meterUsage: BillingStatus["meterUsage"];
}>("get-usage-billing", {
headers: {
Authorization: `Bearer ${token}`,
},
body: {},
});
if (!usageError && usageData) {
subscription = usageData.subscription;
@@ -154,7 +168,10 @@ export class SaasBillingService {
}
}
} catch (usageError) {
console.warn("[Desktop Billing] Failed to fetch usage data:", usageError);
console.warn(
"[Desktop Billing] Failed to fetch usage data:",
usageError,
);
}
}
@@ -177,11 +194,18 @@ export class SaasBillingService {
creditBalance = typeof credits === "number" ? credits : 0;
} else {
const errorText = await creditResponse.text();
console.warn("[Desktop Billing] Failed to fetch credit balance:", creditResponse.status, errorText);
console.warn(
"[Desktop Billing] Failed to fetch credit balance:",
creditResponse.status,
errorText,
);
creditBalance = 0;
}
} catch (error) {
console.error("[Desktop Billing] Error fetching credit balance:", error);
console.error(
"[Desktop Billing] Error fetching credit balance:",
error,
);
creditBalance = 0;
}
@@ -225,18 +249,27 @@ export class SaasBillingService {
try {
// Call Supabase edge function to get Stripe portal URL
const { data, error } = await supabase.functions.invoke<ManageBillingResponse>("manage-billing", {
headers: {
Authorization: `Bearer ${token}`,
},
body: {
return_url: returnUrl,
},
});
const { data, error } =
await supabase.functions.invoke<ManageBillingResponse>(
"manage-billing",
{
headers: {
Authorization: `Bearer ${token}`,
},
body: {
return_url: returnUrl,
},
},
);
if (error) {
console.error("[Desktop Billing] Error creating billing portal session:", error);
throw new Error(error.message || "Failed to create billing portal session");
console.error(
"[Desktop Billing] Error creating billing portal session:",
error,
);
throw new Error(
error.message || "Failed to create billing portal session",
);
}
if (!data || !data.url) {
@@ -260,7 +293,9 @@ export class SaasBillingService {
* Fetch available plan pricing from Stripe
* Calls stripe-price-lookup edge function to get current pricing for all plans
*/
async getAvailablePlans(currencyCode: string = "usd"): Promise<Map<string, PlanPrice>> {
async getAvailablePlans(
currencyCode: string = "usd",
): Promise<Map<string, PlanPrice>> {
// Check if in SaaS mode
const isAvailable = await this.isBillingAvailable();
if (!isAvailable) {
@@ -335,21 +370,27 @@ export class SaasBillingService {
try {
// Call Supabase edge function to create checkout session
// Use 'hosted' mode for browser redirect instead of 'embedded'
const { data, error } = await supabase.functions.invoke<{ url: string }>("create-checkout", {
headers: {
Authorization: `Bearer ${token}`,
const { data, error } = await supabase.functions.invoke<{ url: string }>(
"create-checkout",
{
headers: {
Authorization: `Bearer ${token}`,
},
body: {
ui_mode: "hosted",
success_url: `${returnUrl}/checkout/success`,
cancel_url: `${returnUrl}/checkout/cancel`,
purchase_type: "subscription",
plan: planId,
},
},
body: {
ui_mode: "hosted",
success_url: `${returnUrl}/checkout/success`,
cancel_url: `${returnUrl}/checkout/cancel`,
purchase_type: "subscription",
plan: planId,
},
});
);
if (error) {
console.error("[Desktop Billing] Error creating checkout session:", error);
console.error(
"[Desktop Billing] Error creating checkout session:",
error,
);
throw new Error(error.message || "Failed to create checkout session");
}
@@ -361,7 +402,10 @@ export class SaasBillingService {
// Open in system browser (same pattern as billing portal)
await shellOpen(data.url);
} catch (error) {
console.error("[Desktop Billing] Failed to create checkout session:", error);
console.error(
"[Desktop Billing] Failed to create checkout session:",
error,
);
if (error instanceof Error) {
throw error;
@@ -14,7 +14,8 @@ import { alert } from "@app/components/toast";
export function handleSaaSError(error: unknown): boolean {
if ((error as any)?.config?._isSaaSRequest !== true) return false;
const { title: originalTitle, body: originalBody } = extractAxiosErrorMessage(error);
const { title: originalTitle, body: originalBody } =
extractAxiosErrorMessage(error);
alert({
alertType: "error",
@@ -24,6 +25,9 @@ export function handleSaaSError(error: unknown): boolean {
isPersistentPopup: false,
});
console.error("[saasErrorInterceptor] SaaS backend error:", { originalTitle, originalBody });
console.error("[saasErrorInterceptor] SaaS backend error:", {
originalTitle,
originalBody,
});
return true;
}
@@ -119,7 +119,9 @@ class SelfHostedServerMonitor {
const next = { ...this.state, ...partial };
const changed =
next.status !== this.state.status || next.isOnline !== this.state.isOnline || next.serverUrl !== this.state.serverUrl;
next.status !== this.state.status ||
next.isOnline !== this.state.isOnline ||
next.serverUrl !== this.state.serverUrl;
this.state = next;
@@ -133,7 +133,9 @@ export class TauriBackendService {
} else {
// Don't call setStatus('unhealthy') — the status is already unhealthy and calling it
// again would bypass the dedup check and re-trigger scheduleRecovery.
console.error("[TauriBackendService] Max restart attempts reached, backend is permanently unhealthy.");
console.error(
"[TauriBackendService] Max restart attempts reached, backend is permanently unhealthy.",
);
}
}
}
@@ -200,7 +202,9 @@ export class TauriBackendService {
if (port) {
this.backendPort = port;
// Notify status listeners so hooks reading getBackendUrl() re-evaluate
this.statusListeners.forEach((listener) => listener(this.backendStatus));
this.statusListeners.forEach((listener) =>
listener(this.backendStatus),
);
return;
}
} catch (error) {
@@ -231,25 +235,36 @@ export class TauriBackendService {
return false;
}
if (!this.backendPort) {
console.debug("[TauriBackendService] Health check: backend port not available");
console.debug(
"[TauriBackendService] Health check: backend port not available",
);
return false;
}
const configUrl = `http://localhost:${this.backendPort}/api/v1/config/app-config`;
console.debug(`[TauriBackendService] Checking local backend health at: ${configUrl}`);
console.debug(
`[TauriBackendService] Checking local backend health at: ${configUrl}`,
);
try {
const response = await fetch(configUrl, { method: "GET", connectTimeout: 5000 });
const response = await fetch(configUrl, {
method: "GET",
connectTimeout: 5000,
});
if (!response.ok) {
console.warn(`[TauriBackendService] Health check failed: ${response.status}`);
console.warn(
`[TauriBackendService] Health check failed: ${response.status}`,
);
this.setStatus("unhealthy");
return false;
}
const data = await response.json();
const dependenciesReady = data.dependenciesReady === true;
console.debug(`[TauriBackendService] dependenciesReady=${dependenciesReady}`);
console.debug(
`[TauriBackendService] dependenciesReady=${dependenciesReady}`,
);
this.setStatus(dependenciesReady ? "healthy" : "starting");
return dependenciesReady;
+124 -34
View File
@@ -42,18 +42,28 @@ export interface TauriHttpError extends Error {
toJSON: () => object;
}
type RequestInterceptor = (config: TauriHttpRequestConfig) => Promise<TauriHttpRequestConfig> | TauriHttpRequestConfig;
type ResponseInterceptor<T = any> = (response: TauriHttpResponse<T>) => Promise<TauriHttpResponse<T>> | TauriHttpResponse<T>;
type RequestInterceptor = (
config: TauriHttpRequestConfig,
) => Promise<TauriHttpRequestConfig> | TauriHttpRequestConfig;
type ResponseInterceptor<T = any> = (
response: TauriHttpResponse<T>,
) => Promise<TauriHttpResponse<T>> | TauriHttpResponse<T>;
type ErrorInterceptor = (error: any) => Promise<any>;
interface Interceptors {
request: {
handlers: RequestInterceptor[];
use: (onFulfilled: RequestInterceptor, onRejected?: ErrorInterceptor) => number;
use: (
onFulfilled: RequestInterceptor,
onRejected?: ErrorInterceptor,
) => number;
};
response: {
handlers: { fulfilled: ResponseInterceptor; rejected?: ErrorInterceptor }[];
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => number;
use: (
onFulfilled: ResponseInterceptor,
onRejected?: ErrorInterceptor,
) => number;
};
}
@@ -71,15 +81,24 @@ class TauriHttpClient {
public interceptors: Interceptors = {
request: {
handlers: [],
use: (onFulfilled: RequestInterceptor, _onRejected?: ErrorInterceptor) => {
use: (
onFulfilled: RequestInterceptor,
_onRejected?: ErrorInterceptor,
) => {
this.interceptors.request.handlers.push(onFulfilled);
return this.interceptors.request.handlers.length - 1;
},
},
response: {
handlers: [],
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => {
this.interceptors.response.handlers.push({ fulfilled: onFulfilled, rejected: onRejected });
use: (
onFulfilled: ResponseInterceptor,
onRejected?: ErrorInterceptor,
) => {
this.interceptors.response.handlers.push({
fulfilled: onFulfilled,
rejected: onRejected,
});
return this.interceptors.response.handlers.length - 1;
},
},
@@ -147,11 +166,13 @@ class TauriHttpClient {
// Add query parameters
if (config.params && typeof config.params === "object") {
const searchParams = new URLSearchParams();
Object.entries(config.params as Record<string, unknown>).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
searchParams.append(key, String(value));
}
});
Object.entries(config.params as Record<string, unknown>).forEach(
([key, value]) => {
if (value !== null && value !== undefined) {
searchParams.append(key, String(value));
}
},
);
const queryString = searchParams.toString();
if (queryString) {
url += (url.includes("?") ? "&" : "?") + queryString;
@@ -161,7 +182,9 @@ class TauriHttpClient {
return url;
}
private async executeRequest<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
private async executeRequest<T = any>(
config: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
// Merge with defaults
const mergedConfig: TauriHttpRequestConfig = {
...this.defaults,
@@ -202,7 +225,9 @@ class TauriHttpClient {
try {
// Convert withCredentials to fetch API's credentials option
const credentials: RequestCredentials = finalConfig.withCredentials ? "include" : "omit";
const credentials: RequestCredentials = finalConfig.withCredentials
? "include"
: "omit";
// Make the request using Tauri's native HTTP client (standard Fetch API)
// Enable certificate bypass for HTTPS to handle missing intermediate certs and self-signed certs
@@ -246,7 +271,8 @@ class TauriHttpClient {
}
// Create more descriptive error messages based on status code
let errorMessage = errorBody || `Request failed with status code ${response.status}`;
let errorMessage =
errorBody || `Request failed with status code ${response.status}`;
let errorCode = "ERR_BAD_REQUEST";
if (response.status === 401) {
@@ -256,12 +282,17 @@ class TauriHttpClient {
errorMessage = "Access denied - Insufficient permissions";
errorCode = "ERR_FORBIDDEN";
} else if (response.status === 404) {
errorMessage = "Endpoint not found - Server may not support this operation";
errorMessage =
"Endpoint not found - Server may not support this operation";
errorCode = "ERR_NOT_FOUND";
} else if (response.status === 500) {
errorMessage = "Internal server error - Please check server logs";
errorCode = "ERR_SERVER_ERROR";
} else if (response.status === 502 || response.status === 503 || response.status === 504) {
} else if (
response.status === 502 ||
response.status === 503 ||
response.status === 504
) {
errorMessage = "Server unavailable or timeout - Please try again";
errorCode = "ERR_SERVICE_UNAVAILABLE";
}
@@ -282,7 +313,12 @@ class TauriHttpClient {
config: finalConfig,
};
const error = this.createError(errorMessage, finalConfig, errorCode, errorResponse);
const error = this.createError(
errorMessage,
finalConfig,
errorCode,
errorResponse,
);
// Run error interceptors
let finalError: unknown = error;
@@ -312,7 +348,8 @@ class TauriHttpClient {
// Set it manually to match axios behavior
const blob = await response.blob();
if (!blob.type) {
const contentType = response.headers.get("content-type") || "application/octet-stream";
const contentType =
response.headers.get("content-type") || "application/octet-stream";
data = new Blob([blob], { type: contentType }) as T;
} else {
data = blob as T;
@@ -334,7 +371,9 @@ class TauriHttpClient {
// Run response interceptors
let finalResponse = httpResponse;
for (const handler of this.interceptors.response.handlers) {
finalResponse = (await Promise.resolve(handler.fulfilled(finalResponse))) as TauriHttpResponse<T>;
finalResponse = (await Promise.resolve(
handler.fulfilled(finalResponse),
)) as TauriHttpResponse<T>;
}
return finalResponse;
@@ -352,7 +391,10 @@ class TauriHttpClient {
const errMsg = error.message.toLowerCase();
// Connection refused - server not running or wrong port
if (errMsg.includes("connection refused") || errMsg.includes("econnrefused")) {
if (
errMsg.includes("connection refused") ||
errMsg.includes("econnrefused")
) {
errorMessage = `Unable to connect to server at ${url}. Server may not be running or port is incorrect.`;
errorCode = "ERR_CONNECTION_REFUSED";
}
@@ -382,7 +424,11 @@ class TauriHttpClient {
errorCode = "ERR_SSL_ERROR";
}
// Protocol errors - wrong protocol (http vs https)
else if (errMsg.includes("protocol") || errMsg.includes("https") || errMsg.includes("http")) {
else if (
errMsg.includes("protocol") ||
errMsg.includes("https") ||
errMsg.includes("http")
) {
errorMessage = `Protocol error connecting to ${url}. Try using https:// instead of http:// or vice versa.`;
errorCode = "ERR_PROTOCOL";
}
@@ -408,7 +454,13 @@ class TauriHttpClient {
console.error("[TauriHttpClient] Unknown error type:", error);
}
const httpError = this.createError(errorMessage, finalConfig, errorCode, undefined, error);
const httpError = this.createError(
errorMessage,
finalConfig,
errorCode,
undefined,
error,
);
// Run error interceptors
let finalError: unknown = httpError;
@@ -425,35 +477,61 @@ class TauriHttpClient {
}
}
async request<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async request<T = any>(
config: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>(config);
}
async get<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async get<T = any>(
url: string,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: "GET", url });
}
async delete<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async delete<T = any>(
url: string,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: "DELETE", url });
}
async head<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async head<T = any>(
url: string,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: "HEAD", url });
}
async options<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async options<T = any>(
url: string,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: "OPTIONS", url });
}
async post<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async post<T = any>(
url: string,
data?: any,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: "POST", url, data });
}
async put<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async put<T = any>(
url: string,
data?: any,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: "PUT", url, data });
}
async patch<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async patch<T = any>(
url: string,
data?: any,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: "PATCH", url, data });
}
@@ -466,7 +544,11 @@ class TauriHttpClient {
return this.buildUrl({ ...this.defaults, ...config });
}
async postForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async postForm<T = any>(
url: string,
data?: any,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === "object") {
Object.entries(data).forEach(([key, value]) => {
@@ -476,7 +558,11 @@ class TauriHttpClient {
return this.post<T>(url, formData, config);
}
async putForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async putForm<T = any>(
url: string,
data?: any,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === "object") {
Object.entries(data).forEach(([key, value]) => {
@@ -486,7 +572,11 @@ class TauriHttpClient {
return this.put<T>(url, formData, config);
}
async patchForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
async patchForm<T = any>(
url: string,
data?: any,
config?: TauriHttpRequestConfig,
): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === "object") {
Object.entries(data).forEach(([key, value]) => {
+8 -1
View File
@@ -8,5 +8,12 @@
"@core/*": ["src/core/*"]
}
},
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "../core/setupTests.ts", "."]
"include": [
"../global.d.ts",
"../*.js",
"../*.ts",
"../*.tsx",
"../core/setupTests.ts",
"."
]
}