mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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,
|
||||
|
||||
+3
-1
@@ -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>
|
||||
|
||||
+202
-44
@@ -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>
|
||||
|
||||
+42
-9
@@ -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>
|
||||
|
||||
+28
-5
@@ -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
-8
@@ -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>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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",
|
||||
"."
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user