Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
+51
View File
@@ -0,0 +1,51 @@
Stirling PDF User License
Copyright (c) 2025 Stirling PDF Inc.
License Scope & Usage Rights
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
covering the appropriate number of licensed users.
Trial and Minimal Use
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
* Use is limited to the capabilities and restrictions defined by the Software itself;
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
Continued use beyond this scope requires a valid Stirling PDF User License.
Modifications and Derivative Works
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
* May not be deployed in production environments without a valid User License;
* May not be distributed or sublicensed;
* Remain the intellectual property of Stirling PDF and/or its licensors;
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
Prohibited Actions
Unless explicitly permitted by a paid license or separate agreement, you may not:
* Use the Software in production environments;
* Copy, merge, distribute, sublicense, or sell the Software;
* Remove or alter any licensing or copyright notices;
* Circumvent access restrictions or licensing requirements.
Third-Party Components
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
their original license terms as provided by their respective owners.
Disclaimer
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,34 @@
import { createClient } from "@supabase/supabase-js";
import { STIRLING_SAAS_URL, SUPABASE_KEY } from "@app/constants/connection";
/**
* Supabase client for desktop application
* Used to call Supabase edge functions for billing and other SaaS features
*
* Note: Desktop uses authService for authentication (JWT stored in Tauri secure store),
* but this client is needed for calling Supabase edge functions like get-usage-billing
*/
if (!STIRLING_SAAS_URL) {
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",
);
}
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
},
},
);
@@ -0,0 +1,322 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal";
import { SignInModal } from "@app/components/SignInModal";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import { ToolActionsContext } from "@app/contexts/ToolActionsContext";
import { useFirstLaunchCheck } from "@app/hooks/useFirstLaunchCheck";
import { useBackendInitializer } from "@app/hooks/useBackendInitializer";
import { DESKTOP_DEFAULT_APP_CONFIG } from "@app/config/defaultAppConfig";
import {
connectionModeService,
JWT_EXPIRED_PROMPTED_KEY,
} from "@app/services/connectionModeService";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { authService } from "@app/services/authService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { isTauri } from "@tauri-apps/api/core";
import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
// Common tool endpoints to preload for faster first-use
const COMMON_TOOL_ENDPOINTS = [
"/api/v1/misc/compress-pdf",
"/api/v1/general/merge-pdfs",
"/api/v1/general/split-pages",
"/api/v1/convert/pdf/img",
"/api/v1/convert/img/pdf",
"/api/v1/general/rotate-pdf",
"/api/v1/misc/add-watermark",
"/api/v1/security/add-password",
"/api/v1/security/remove-password",
"/api/v1/general/extract-pages",
];
/**
* Desktop application providers
* Wraps proprietary providers and adds desktop-specific configuration
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
* - Shows setup wizard on first launch
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
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
const firstLaunchInitiated = useRef(false);
// Key incremented on every connection mode change after initial load — forces SaaS provider
// tree to remount without a full page reload (avoids Windows WebView2 freeze on window.location.reload()).
const [appKey, setAppKey] = useState(0);
const hasLoadedInitialMode = useRef(false);
// Load connection mode on mount and subscribe to future changes
useEffect(() => {
void connectionModeService.getCurrentMode().then((mode) => {
setConnectionMode(mode);
hasLoadedInitialMode.current = true;
});
const unsub = connectionModeService.subscribeToModeChanges((config) => {
setConnectionMode(config.mode);
// Remount the SaaS provider tree when transitioning between saas/local modes so
// Supabase client state is reset without a full page reload (avoids the Windows
// WebView2 freeze that window.location.reload() causes during an OAuth flow).
// Switching TO selfhosted skips the remount — self-hosted mode doesn't use the
// SaaS providers and remounting mid-wizard resets authChecked, navigating away.
// Switching FROM selfhosted TO saas DOES trigger a remount (mode !== 'selfhosted')
// which is intentional — the SaaS provider tree needs fresh state after login.
if (hasLoadedInitialMode.current && config.mode !== "selfhosted") {
setAppKey((k) => k + 1);
}
});
return unsub;
}, []);
useEffect(() => {
// Wait until connection mode is loaded before checking auth
if (connectionMode === null) return;
if (!isFirstLaunch && setupComplete) {
if (connectionMode === "local") {
// Even in local mode, check for a valid JWT — on Windows, the OAuth callback
// can complete without switchToSaaS() being called (race condition), leaving
// LOCAL_MODE_STORAGE_KEY set while the user has a valid session. Upgrade to
// SaaS mode automatically so credits/billing/team features work correctly.
authService
.isAuthenticated()
.then(async (isAuth) => {
if (isAuth) {
await connectionModeService
.switchToSaaS(STIRLING_SAAS_URL)
.catch(console.error);
setConnectionMode("saas");
}
})
.finally(() => setAuthChecked(true));
} else {
authService
.isAuthenticated()
.then(async (isAuth) => {
if (!isAuth) {
const cfg = await connectionModeService
.getCurrentConfig()
.catch(() => null);
if (!cfg?.lock_connection_mode) {
// JWT expired — fall back to local so local tools still work.
await connectionModeService
.switchToLocal()
.catch(console.error);
setConnectionMode("local");
// Show sign-in modal once per expiry cycle. If the user dismisses
// without signing in the flag stays set and we won't prompt again
// until they successfully sign in (which clears the flag).
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
setPendingSignIn(true);
}
}
// Locked deployments stay in their configured mode — user can sign in
// via Settings when they're ready.
}
})
.catch(async () => {
const cfg = await connectionModeService
.getCurrentConfig()
.catch(() => null);
if (!cfg?.lock_connection_mode) {
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode("local");
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
setPendingSignIn(true);
}
}
})
.finally(() => setAuthChecked(true));
}
} else if (isFirstLaunch && !setupComplete) {
// Guard against re-running when setConnectionMode triggers this effect.
if (firstLaunchInitiated.current) return;
firstLaunchInitiated.current = true;
connectionModeService
.getCurrentConfig()
.then(async (cfg) => {
if (cfg.lock_connection_mode && cfg.server_config?.url) {
// Locked provisioned deployment — do NOT switch to local (would clear server_config
// from the store). Show onboarding normally; the sign-in slide handles locked auth.
// Still start the local backend so local tools work while the user signs in.
await tauriBackendService.startBackend().catch(console.error);
setConnectionMode("selfhosted");
} else {
// Normal first launch — auto-enter local mode.
// The onboarding carousel + sign-in slide will be shown inside the main app.
await connectionModeService.switchToLocal();
await tauriBackendService.startBackend();
setConnectionMode("local");
}
})
.catch(console.error)
.finally(() => setAuthChecked(true));
}
}, [isFirstLaunch, setupComplete, connectionMode]);
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
if (connectionMode !== "selfhosted") {
// Stop the monitor whenever we leave selfhosted mode so the dot resets.
selfHostedServerMonitor.stop();
return;
}
if (setupComplete && !isFirstLaunch) {
void tauriBackendService.initializeExternalBackend();
connectionModeService.getServerConfig().then((cfg) => {
if (cfg?.url) {
selfHostedServerMonitor.start(cfg.url);
}
});
}
return () => {
selfHostedServerMonitor.stop();
};
}, [setupComplete, isFirstLaunch, connectionMode]);
// 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");
useBackendInitializer(shouldMonitorBackend);
// Preload endpoint availability for the local bundled backend.
// SaaS mode: triggers when the bundled backend reports healthy.
// Self-hosted mode: triggers when the local bundled backend port is discovered
// (so useSelfHostedToolAvailability can use the cache instead of making
// individual requests per-tool when the remote server goes offline).
const shouldPreloadLocalEndpoints =
(setupComplete && !isFirstLaunch && connectionMode === "saas") ||
(setupComplete && !isFirstLaunch && connectionMode === "local") ||
(setupComplete && !isFirstLaunch && connectionMode === "selfhosted");
useEffect(() => {
if (!shouldPreloadLocalEndpoints) return;
const tryPreload = () => {
const backendUrl = tauriBackendService.getBackendUrl();
if (!backendUrl) return;
// 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,
);
};
const unsubscribe = tauriBackendService.subscribeToStatus(() =>
tryPreload(),
);
tryPreload();
return unsubscribe;
}, [shouldPreloadLocalEndpoints, connectionMode]);
// Dispatch sign-in modal after authChecked so SignInModal's listener is registered.
// (Child effects run before parent effects, so this fires after SignInModal mounts.)
// detail.locked is always false here: setPendingSignIn(true) is only called inside
// `if (!cfg?.lock_connection_mode)` branches above, so locked deployments never set
// pendingSignIn and therefore never reach this dispatch.
useEffect(() => {
if (!authChecked || !pendingSignIn) return;
window.dispatchEvent(
new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }),
);
setPendingSignIn(false);
}, [authChecked, pendingSignIn]);
useEffect(() => {
if (!authChecked) {
return;
}
if (!isTauri()) {
return;
}
const currentWindow = getCurrentWindow();
currentWindow
.show()
.then(() => currentWindow.unminimize().catch(() => {}))
.then(() => currentWindow.setFocus().catch(() => {}))
.then(() => currentWindow.requestUserAttention(1).catch(() => {}))
.catch(() => {});
}, [authChecked]);
if (!authChecked) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: "non-blocking",
autoFetch: false,
}}
>
<div style={{ minHeight: "100vh" }} />
</ProprietaryAppProviders>
);
}
// Normal app flow
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: "non-blocking",
autoFetch: false,
}}
>
<ToolActionsContext.Provider
value={{
onEndpointUnavailableClick: () =>
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
}}
>
<SaaSTeamProvider key={appKey}>
<SaasBillingProvider>
<SaaSCheckoutProvider>
<DesktopConfigSync />
<DesktopBannerInitializer />
<SaveShortcutListener />
<CreditModalBootstrap />
{children}
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
<DesktopOnboardingModal />
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
<SignInModal />
</SaaSCheckoutProvider>
</SaasBillingProvider>
</SaaSTeamProvider>
</ToolActionsContext.Provider>
</ProprietaryAppProviders>
);
}
@@ -0,0 +1,79 @@
import React, { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Box, Tooltip, useMantineTheme, rem } from "@mantine/core";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
interface BackendHealthIndicatorProps {
className?: string;
}
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
className = "",
}) => {
const { t } = useTranslation();
const theme = useMantineTheme();
const { status, isOnline, checkHealth } = useBackendHealth();
const label = useMemo(() => {
if (status === "starting") {
return t("backendHealth.checking", "Checking backend status...");
}
if (isOnline) {
return t("backendHealth.online", "Backend Online");
}
return t("backendHealth.offline", "Backend Offline");
}, [status, isOnline, t]);
const dotColor = useMemo(() => {
if (status === "starting") {
return theme.colors.yellow?.[5] ?? "#fcc419";
}
if (isOnline) {
return theme.colors.green?.[5] ?? "#37b24d";
}
return theme.colors.red?.[6] ?? "#e03131";
}, [
status,
isOnline,
theme.colors.green,
theme.colors.red,
theme.colors.yellow,
]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
checkHealth();
}
},
[checkHealth],
);
return (
<Tooltip label={label} position="left" offset={12} withArrow withinPortal>
<Box
component="span"
className={className ? `${className}` : undefined}
role="status"
aria-live="polite"
aria-label={label}
tabIndex={0}
onClick={checkHealth}
onKeyDown={handleKeyDown}
style={{
width: rem(12),
height: rem(12),
borderRadius: "50%",
backgroundColor: dotColor,
boxShadow: "var(--status-dot-ring)",
cursor: "pointer",
display: "inline-block",
outline: "none",
}}
/>
</Tooltip>
);
};
@@ -0,0 +1,157 @@
import React, { useState, useEffect } from "react";
import { Stack, Card, Badge, Button, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
connectionModeService,
ConnectionConfig,
} from "@app/services/connectionModeService";
import { authService, UserInfo } from "@app/services/authService";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
// Load current config on mount
useEffect(() => {
const loadConfig = async () => {
const currentConfig = await connectionModeService.getCurrentConfig();
setConfig(currentConfig);
if (
currentConfig.mode === "saas" ||
currentConfig.mode === "selfhosted"
) {
const user = await authService.getUserInfo();
setUserInfo(user);
}
};
loadConfig();
const unsubscribe =
connectionModeService.subscribeToModeChanges(loadConfig);
return unsubscribe;
}, []);
const handleLogout = async () => {
try {
setLoading(true);
// Save server URL before clearing so user can easily reconnect (self-hosted only)
if (config?.mode === "selfhosted" && config?.server_config?.url) {
localStorage.setItem("server_url", config.server_config.url);
}
await authService.logout();
// Always switch to local after logout so the app remains usable
await connectionModeService.switchToLocal();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
// Clear URL to home page so we don't return to settings after re-login
window.history.replaceState({}, "", "/");
// No reload needed — AppProviders remounts the SaaS provider tree via
// connectionModeService subscription when mode changes to local.
} catch (error) {
console.error("Logout failed:", error);
} finally {
setLoading(false);
}
};
const handleSignIn = () => {
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
};
if (!config) {
return <Text>{t("common.loading", "Loading...")}</Text>;
}
return (
<>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>
{t("settings.connection.title", "Connection Mode")}
</Text>
<Badge
color={
config.mode === "saas"
? "blue"
: config.mode === "local"
? "white"
: "green"
}
variant="light"
>
{config.mode === "saas"
? t("settings.connection.mode.saas", "Stirling Cloud")
: config.mode === "local"
? t("settings.connection.mode.local", "Local Only")
: t("settings.connection.mode.selfhosted", "Self-Hosted")}
</Badge>
</Group>
{config.mode === "local" && (
<Text size="sm" c="dimmed">
{t(
"settings.connection.localDescription",
"You are using the local backend without an account. Some tools requiring cloud processing or a self-hosted server are unavailable.",
)}
</Text>
)}
{(config.mode === "saas" || config.mode === "selfhosted") &&
config.server_config && (
<>
<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 && (
<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" ? (
<Button onClick={handleSignIn} color="blue" variant="light">
{t("settings.connection.signIn", "Sign In")}
</Button>
) : (
<Button
onClick={handleLogout}
color="red"
variant="light"
disabled={loading}
>
{t("settings.connection.logout", "Log Out")}
</Button>
)}
</Group>
</Stack>
</Card>
</>
);
};
@@ -0,0 +1,26 @@
import { useEffect } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import { DefaultAppBanner } from "@app/components/shared/DefaultAppBanner";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
import { TeamInvitationBanner } from "@app/components/shared/TeamInvitationBanner";
import { SelfHostedOfflineBanner } from "@app/components/shared/SelfHostedOfflineBanner";
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(
<>
<SelfHostedOfflineBanner />
<TeamInvitationBanner />
<UpgradeBanner />
<DefaultAppBanner />
</>,
);
return () => {
setBanner(null);
};
}, [setBanner]);
return null;
}
@@ -0,0 +1,23 @@
import { useEffect, useRef } from "react";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
import { useAppConfig } from "@app/contexts/AppConfigContext";
/**
* Desktop-only bridge that refetches the app config once the bundled backend
* becomes healthy (and whenever it restarts). Keeps the UI responsive by using
* default config until the real config is available.
*/
export function DesktopConfigSync() {
const { status } = useBackendHealth();
const { refetch } = useAppConfig();
const previousStatus = useRef(status);
useEffect(() => {
if (status === "healthy" && previousStatus.current !== "healthy") {
refetch();
}
previousStatus.current = status;
}, [status, refetch]);
return null;
}
@@ -0,0 +1,197 @@
import { useState, useMemo } from "react";
import { Modal, Stack, Group, Button, ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
import LocalIcon from "@app/components/shared/LocalIcon";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
import { SetupWizard } from "@app/components/SetupWizard";
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { connectionModeService } from "@app/services/connectionModeService";
const ONBOARDING_KEY = "stirling-desktop-onboarding-seen";
const SIGN_IN_GRADIENT: [string, string] = ["#3B82F6", "#7C3AED"];
/**
* Desktop-specific onboarding modal.
* Shown on first launch: welcome slide → sign-in slide.
* Replaces the core onboarding (which targets server/admin users).
*/
export function DesktopOnboardingModal() {
const { t } = useTranslation();
const [visible, setVisible] = useState(
() => !localStorage.getItem(ONBOARDING_KEY),
);
const [step, setStep] = useState(0);
const dismissFinal = () => {
localStorage.setItem(ONBOARDING_KEY, "true");
setVisible(false);
// If the user dismissed the sign-in slide without authenticating, fall back to local mode
// so the app is usable without a server connection.
connectionModeService.switchToLocal().catch(console.error);
};
// X on slide 0 advances to sign-in slide rather than dismissing entirely
const handleClose = () => {
if (step === 0) {
setStep(1);
} else {
dismissFinal();
}
};
const handleComplete = () => {
localStorage.setItem(ONBOARDING_KEY, "true");
setVisible(false);
// No reload needed — AppProviders subscribes to connectionModeService and remounts
// the SaaS provider tree when mode changes, avoiding the Windows WebView2 freeze
// that window.location.reload() causes during a backgrounded OAuth flow.
};
// Call WelcomeSlide as a data factory (not a component render) — memoised so it
// isn't reconstructed on every render while the modal is open.
const welcomeSlide = useMemo(() => WelcomeSlide(), []);
const totalSteps = 2;
if (!visible) return null;
return (
<Modal
opened={visible}
onClose={handleClose}
closeOnClickOutside={step === 1}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack
gap={0}
className={styles.modalContent}
style={{
height: "100%",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
}}
>
{/* Hero section — gradient changes per slide */}
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={
step === 0
? welcomeSlide.background.gradientStops
: SIGN_IN_GRADIENT
}
circles={welcomeSlide.background.circles}
isActive
slideKey={step === 0 ? "desktop-welcome" : "desktop-sign-in"}
/>
<ActionIcon
onClick={handleClose}
radius="md"
size={36}
style={{
position: "absolute",
top: 16,
right: 16,
backgroundColor: "rgba(255, 255, 255, 0.2)",
color: "white",
backdropFilter: "blur(4px)",
zIndex: 10,
}}
styles={{
root: {
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.3)" },
},
}}
>
<CloseIcon fontSize="small" />
</ActionIcon>
<div className={styles.heroLogo} key={`logo-${step}`}>
<div className={styles.heroLogoCircle}>
{step === 0 ? (
<LocalIcon
icon="rocket-launch"
width={64}
height={64}
className={styles.heroIcon}
/>
) : (
<LocalIcon
icon="login"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
</div>
</div>
</div>
{/* Body section */}
<div
className={styles.modalBody}
style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}
>
{step === 0 ? (
// Welcome slide
<Stack gap={16}>
<div className={`${styles.title} ${styles.titleText}`}>
{welcomeSlide.title}
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{welcomeSlide.body}
</div>
<style>{`.${styles.bodyCopyInner} strong { color: var(--onboarding-title); font-weight: 600; }`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
<div className={styles.buttonContainer}>
<Group justify="flex-end">
<Button
onClick={() => setStep(1)}
styles={{
root: {
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}}
>
{t("onboarding.buttons.next", "Next →")}
</Button>
</Group>
</div>
</Stack>
) : (
// Sign-in slide
<Stack gap={12}>
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
<SetupWizard
noLayout
onComplete={handleComplete}
onClose={dismissFinal}
/>
</Stack>
)}
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,14 @@
import { useSaveShortcut } from "@app/hooks/useSaveShortcut";
import { useExitWarning } from "@app/hooks/useExitWarning";
/**
* Desktop-only component that sets up keyboard shortcuts and exit warnings
* - Ctrl/Cmd+S to save selected files
* - Warning on app exit if unsaved files
* Renders nothing, just sets up the listeners
*/
export function SaveShortcutListener() {
useSaveShortcut();
useExitWarning();
return null;
}
@@ -0,0 +1,84 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
interface DesktopAuthLayoutProps {
children: React.ReactNode;
}
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],
);
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute(
"data-mantine-color-scheme",
);
// Set light mode
htmlElement.setAttribute("data-mantine-color-scheme", "light");
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute(
"data-mantine-color-scheme",
previousColorScheme,
);
}
};
}, []);
useEffect(() => {
const update = () => {
// Use viewport to avoid hysteresis when the card is already in single-column mode
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2;
const tooNarrow = columnWidth < 470;
const tooShort = viewportHeight < 740;
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
};
}, []);
return (
<div className={styles.authContainer}>
<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}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,189 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { authService, UserInfo } from "@app/services/authService";
import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml";
import { BASE_PATH } from "@app/constants/app";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import "@app/components/SetupWizard/desktopOAuth.css";
type KnownProviderId =
| "google"
| "github"
| "keycloak"
| "azure"
| "apple"
| "oidc";
export type OAuthProviderId = KnownProviderId | string;
export interface DesktopSSOProvider {
id: OAuthProviderId;
path?: string;
label?: string;
}
interface DesktopOAuthButtonsProps {
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
onError: (error: string) => void;
isDisabled: boolean;
serverUrl: string;
providers: DesktopSSOProvider[];
mode?: "saas" | "selfHosted";
}
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
onOAuthSuccess,
onError,
isDisabled,
serverUrl,
providers,
mode = "saas",
}) => {
const { t } = useTranslation();
const [oauthLoading, setOauthLoading] = useState(false);
const handleOAuthLogin = async (provider: DesktopSSOProvider) => {
// Prevent concurrent OAuth attempts
if (oauthLoading || isDisabled) {
return;
}
try {
setOauthLoading(true);
// 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.",
),
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.",
),
isError: true,
errorPlaceholder: true, // {error} will be replaced by Rust
});
const normalizedServer = serverUrl.replace(/\/+$/, "");
const usingSupabaseFlow =
mode === "saas" ||
normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, "");
const userInfo = usingSupabaseFlow
? await authService.loginWithOAuth(
provider.id,
serverUrl,
successHtml,
errorHtml,
)
: await authService.loginWithSelfHostedOAuth(
provider.path || provider.id,
serverUrl,
);
// Call the onOAuthSuccess callback to complete setup
await onOAuthSuccess(userInfo);
} catch (error) {
console.error("OAuth login failed:", error);
const errorMessage =
error instanceof Error
? error.message
: t(
"setup.login.error.oauthFailed",
"OAuth login failed. Please try again.",
);
onError(errorMessage);
setOauthLoading(false);
}
};
const providerConfig: Record<
KnownProviderId,
{ label: string; file: string }
> = {
google: { label: "Google", file: "google.svg" },
github: { label: "GitHub", file: "github.svg" },
keycloak: { label: "Keycloak", file: "keycloak.svg" },
azure: { label: "Microsoft", file: "microsoft.svg" },
apple: { label: "Apple", file: "apple.svg" },
oidc: { label: "OpenID", file: "oidc.svg" },
};
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",
);
return null;
}
// Desktop always uses its own styling classes (independent of web)
return (
<div className="oauth-container-vertical-desktop">
{providers
.filter(
(providerConfigEntry) =>
providerConfigEntry && providerConfigEntry.id,
)
.map((providerEntry) => {
const iconConfig = isKnownProvider(providerEntry.id)
? providerConfig[providerEntry.id]
: undefined;
const label =
providerEntry.label ||
iconConfig?.label ||
(providerEntry.id
? providerEntry.id.charAt(0).toUpperCase() +
providerEntry.id.slice(1)
: t("setup.login.sso", "Single Sign-On"));
return (
<button
key={providerEntry.id}
onClick={() => handleOAuthLogin(providerEntry)}
disabled={isDisabled || oauthLoading}
className="oauth-button-vertical-desktop"
title={label}
>
<span className="oauth-button-left-desktop">
<span className="oauth-icon-wrapper-desktop">
<img
src={`${BASE_PATH}/Login/${iconConfig?.file || GENERIC_PROVIDER_ICON}`}
alt={label}
className="oauth-icon-tiny-desktop"
/>
</span>
<span className="oauth-button-text-desktop">{label}</span>
</span>
</button>
);
})}
{oauthLoading && (
<p
style={{
margin: "0.5rem 0",
fontSize: "0.875rem",
color: "#6b7280",
textAlign: "center",
}}
>
{t(
"setup.login.oauthPending",
"Opening browser for authentication...",
)}
</p>
)}
</div>
);
};
@@ -0,0 +1,142 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import DividerWithText from "@app/components/shared/DividerWithText";
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink";
import { UserInfo } from "@app/services/authService";
import "@app/routes/authShared/auth.css";
interface SaaSLoginScreenProps {
serverUrl: string;
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
onSelfHostedClick: () => void;
onSwitchToSignup: () => void;
onSkipSignIn?: () => void;
onClose?: () => void;
loading: boolean;
error: string | null;
}
export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
serverUrl,
onLogin,
onOAuthSuccess,
onSelfHostedClick,
onSwitchToSignup,
onSkipSignIn,
onClose,
loading,
error,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const handleEmailPasswordSubmit = async () => {
// Validation
if (!email.trim()) {
setValidationError(
t("setup.login.error.emptyEmail", "Please enter your email"),
);
return;
}
if (!password) {
setValidationError(
t("setup.login.error.emptyPassword", "Please enter your password"),
);
return;
}
setValidationError(null);
await onLogin(email.trim(), password);
};
const handleOAuthError = (errorMessage: string) => {
setValidationError(errorMessage);
};
const displayError = error || validationError;
return (
<>
<LoginHeader
title={t("setup.saas.title", "Sign in to Stirling Cloud")}
onClose={onClose}
/>
<ErrorMessage error={displayError} />
<DesktopOAuthButtons
onOAuthSuccess={onOAuthSuccess}
onError={handleOAuthError}
isDisabled={loading}
serverUrl={serverUrl}
mode="saas"
providers={[{ id: "google" }, { id: "github" }]}
/>
<DividerWithText
text={t("setup.login.orContinueWith", "Or continue with email")}
respondsToDarkMode={false}
opacity={0.4}
/>
<EmailPasswordForm
email={email}
password={password}
setEmail={(value) => {
setEmail(value);
setValidationError(null);
}}
setPassword={(value) => {
setPassword(value);
setValidationError(null);
}}
onSubmit={handleEmailPasswordSubmit}
isSubmitting={loading}
submitButtonText={t("setup.login.submit", "Login")}
/>
<div
className="navigation-link-container"
style={{ marginTop: "0.5rem", textAlign: "right" }}
>
<button
type="button"
onClick={() => {
setValidationError(null);
onSwitchToSignup();
}}
className="navigation-link-button"
disabled={loading}
>
{t("signup.signUp", "Sign Up")}
</button>
</div>
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
{onSkipSignIn && (
<div
className="navigation-link-container"
style={{ marginTop: "0.5rem", textAlign: "center" }}
>
<button
type="button"
onClick={onSkipSignIn}
className="navigation-link-button"
disabled={loading}
>
{t("setup.login.skipSignIn", "Continue without signing in")}
</button>
</div>
)}
</>
);
};
@@ -0,0 +1,118 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import SignupForm from "@app/routes/signup/SignupForm";
import {
useSignupFormValidation,
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { authService } from "@app/services/authService";
import "@app/routes/authShared/auth.css";
interface SaaSSignupScreenProps {
loading: boolean;
error: string | null;
onLogin: (username: string, password: string) => Promise<void>;
onSwitchToLogin: () => void;
}
export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
loading,
error,
onLogin: _onLogin,
onSwitchToLogin: _onSwitchToLogin,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>(
{},
);
const [signupSuccessMessage, setSignupSuccessMessage] = useState<
string | null
>(null);
const [isSignupSubmitting, setIsSignupSubmitting] = useState(false);
const { validateSignupForm } = useSignupFormValidation();
const displayError = error || validationError;
const handleSignupSubmit = async () => {
setValidationError(null);
setSignupSuccessMessage(null);
setSignupFieldErrors({});
const validation = validateSignupForm(email, password, confirmPassword);
if (!validation.isValid) {
setValidationError(validation.error);
setSignupFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSignupSubmitting(true);
await authService.signUpSaas(email.trim(), password);
setSignupSuccessMessage(
t(
"signup.checkEmailConfirmation",
"Check your email for a confirmation link to complete your registration.",
),
);
setSignupFieldErrors({});
setValidationError(null);
} catch (err) {
setSignupSuccessMessage(null);
const message =
err instanceof Error
? err.message
: t("signup.unexpectedError", { message: "Unknown error" });
setValidationError(message);
} finally {
setIsSignupSubmitting(false);
}
};
return (
<>
<LoginHeader
title={t("signup.title", "Create an account")}
subtitle={t("signup.subtitle", "Join Stirling PDF")}
/>
<ErrorMessage error={displayError} />
{signupSuccessMessage && (
<div className="success-message">
<p className="success-message-text">{signupSuccessMessage}</p>
</div>
)}
<SignupForm
email={email}
password={password}
confirmPassword={confirmPassword}
setEmail={(value) => {
setEmail(value);
setValidationError(null);
setSignupFieldErrors({});
}}
setPassword={(value) => {
setPassword(value);
setValidationError(null);
setSignupFieldErrors({});
}}
setConfirmPassword={(value) => {
setConfirmPassword(value);
setValidationError(null);
setSignupFieldErrors({});
}}
onSubmit={handleSignupSubmit}
isSubmitting={loading || isSignupSubmitting}
fieldErrors={signupFieldErrors}
showName={false}
showTerms={false}
/>
</>
);
};
@@ -0,0 +1,28 @@
import React from "react";
import { useTranslation } from "react-i18next";
import "@app/routes/authShared/auth.css";
interface SelfHostedLinkProps {
onClick: () => void;
disabled?: boolean;
}
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"
>
{t("setup.selfhosted.link", "or connect to a self hosted account")}
</button>
</div>
);
};
@@ -0,0 +1,157 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import DividerWithText from "@app/components/shared/DividerWithText";
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
import { UserInfo } from "@app/services/authService";
import { SSOProviderConfig } from "@app/services/connectionModeService";
import "@app/routes/authShared/auth.css";
interface SelfHostedLoginScreenProps {
serverUrl: string;
enabledOAuthProviders?: SSOProviderConfig[];
loginMethod?: string;
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
mfaCode: string;
setMfaCode: (value: string) => void;
requiresMfa: boolean;
loading: boolean;
error: string | null;
}
export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
serverUrl,
enabledOAuthProviders,
loginMethod = "all",
onLogin,
onOAuthSuccess,
mfaCode,
setMfaCode,
requiresMfa,
loading,
error,
}) => {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
// Check if username/password authentication is allowed
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
console.log("[SelfHostedLoginScreen] Props:", {
serverUrl,
enabledOAuthProviders,
loginMethod,
isUserPassAllowed,
shouldShowOAuth: !!(
enabledOAuthProviders && enabledOAuthProviders.length > 0
),
});
const handleSubmit = async () => {
// Validation
if (!username.trim()) {
setValidationError(
t("setup.login.error.emptyUsername", "Please enter your username"),
);
return;
}
if (!password) {
setValidationError(
t("setup.login.error.emptyPassword", "Please enter your password"),
);
return;
}
if (requiresMfa && !mfaCode.trim()) {
setValidationError(t("login.mfaRequired", "Two-factor code required"));
return;
}
setValidationError(null);
await onLogin(username.trim(), password);
};
const handleOAuthError = (errorMessage: string) => {
setValidationError(errorMessage);
};
const displayError = error || validationError;
return (
<>
<LoginHeader
title={t("setup.selfhosted.title", "Sign in to Server")}
subtitle={
isUserPassAllowed
? t("setup.selfhosted.subtitle", "Enter your server credentials")
: undefined
}
/>
<ErrorMessage error={displayError} />
<Text size="sm" mb="md">
{t("setup.login.connectingTo", "Connecting to:")}{" "}
<Text span fw="500">
{serverUrl}
</Text>
</Text>
{/* Show OAuth buttons if providers are available */}
{enabledOAuthProviders && enabledOAuthProviders.length > 0 && (
<>
<DesktopOAuthButtons
onOAuthSuccess={onOAuthSuccess}
onError={handleOAuthError}
isDisabled={loading}
serverUrl={serverUrl}
mode="selfHosted"
providers={enabledOAuthProviders}
/>
{/* Only show divider if username/password auth is also allowed */}
{isUserPassAllowed && (
<DividerWithText
text={t("setup.login.orContinueWith", "Or continue with email")}
respondsToDarkMode={false}
opacity={0.4}
/>
)}
</>
)}
{/* Only show email/password form if username/password auth is allowed */}
{isUserPassAllowed && (
<EmailPasswordForm
email={username}
password={password}
setEmail={(value) => {
setUsername(value);
setValidationError(null);
}}
setPassword={(value) => {
setPassword(value);
setValidationError(null);
}}
mfaCode={mfaCode}
setMfaCode={(value) => {
setMfaCode(value);
setValidationError(null);
}}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={handleSubmit}
isSubmitting={loading}
submitButtonText={t("setup.login.submit", "Login")}
/>
)}
</>
);
};
@@ -0,0 +1,353 @@
import React, { useState } from "react";
import { Stack, Button, TextInput, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
ServerConfig,
SSOProviderConfig,
} from "@app/services/connectionModeService";
import { connectionModeService } from "@app/services/connectionModeService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface ServerSelectionProps {
onSelect: (config: ServerConfig) => void;
loading: boolean;
}
export const ServerSelection: React.FC<ServerSelectionProps> = ({
onSelect,
loading,
}) => {
const { t } = useTranslation();
const [customUrl, setCustomUrl] = useState("");
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [securityDisabled, setSecurityDisabled] = useState(false);
const serverUrl = localStorage.getItem("server_url") || "";
const submitServerUrl = async () => {
// Normalize and validate URL
let url = customUrl.trim().replace(/\/+$/, "") || serverUrl;
if (!url) {
setTestError(
t("setup.server.error.emptyUrl", "Please enter a server URL"),
);
return;
}
// Auto-add https:// if no protocol specified
if (!url.startsWith("http://") && !url.startsWith("https://")) {
console.log("[ServerSelection] No protocol specified, adding https://");
url = `https://${url}`;
setCustomUrl(url); // Update the input field
}
// Validate URL format
try {
const urlObj = new URL(url);
console.log("[ServerSelection] Valid URL:", {
protocol: urlObj.protocol,
hostname: urlObj.hostname,
port: urlObj.port,
pathname: urlObj.pathname,
});
} catch (err) {
console.error("[ServerSelection] Invalid URL format:", err);
setTestError(
t(
"setup.server.error.invalidUrl",
"Invalid URL format. Please enter a valid URL like https://your-server.com",
),
);
return;
}
// Test connection before proceeding
setTesting(true);
setTestError(null);
setSecurityDisabled(false);
console.log(`[ServerSelection] Testing connection to: ${url}`);
try {
const testResult = await connectionModeService.testConnection(url);
if (!testResult.success) {
console.error("[ServerSelection] Connection test failed:", testResult);
setTestError(
testResult.error ||
t("setup.server.error.unreachable", "Could not connect to server"),
);
setTesting(false);
return;
}
console.log("[ServerSelection] ✅ Connection test successful");
// Fetch OAuth providers and check if login is enabled
const enabledProviders: SSOProviderConfig[] = [];
let loginMethod = "all"; // Default to 'all' (allows both SSO and username/password)
try {
console.log("[ServerSelection] Fetching login configuration...");
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
// Check if security is disabled (status 403, 401, or 404 - endpoint doesn't exist)
if (!response.ok) {
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)",
);
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,
},
),
);
setTesting(false);
return;
}
const data = await response.json();
console.log("[ServerSelection] Login UI data:", data);
// Check if the response indicates security is disabled
if (data.enableLogin === false || data.securityEnabled === false) {
console.log(
"[ServerSelection] Security is explicitly disabled in config",
);
setSecurityDisabled(true);
setTesting(false);
return;
}
// Extract loginMethod from response
loginMethod = data.loginMethod || "all";
console.log("[ServerSelection] Login method:", loginMethod);
// Extract provider IDs from authorization URLs
// Example: "/oauth2/authorization/google" → "google"
const providerEntries = Object.entries(data.providerList || {});
console.log(
"[ServerSelection] providerList from API:",
data.providerList,
);
providerEntries.forEach(([path, label]) => {
const id = path.split("/").pop();
console.log(
"[ServerSelection] Processing provider path:",
path,
"→ id:",
id,
);
if (!id) {
console.warn(
"[ServerSelection] Skipping provider with empty id:",
path,
);
return;
}
enabledProviders.push({
id,
path,
label: typeof label === "string" ? label : undefined,
});
});
console.log(
"[ServerSelection] ✅ Detected OAuth providers:",
enabledProviders,
);
console.log("[ServerSelection] Login method:", loginMethod);
} catch (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)",
);
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,
);
setTestError(
t(
"setup.server.error.configFetchError",
"Failed to fetch server configuration: {{error}}",
{
error: errorMessage,
},
),
);
setTesting(false);
return;
}
// Connection successful — persist URL so it pre-fills on next sign-in
localStorage.setItem("server_url", url);
console.log(
"[ServerSelection] ✅ Server selection complete, proceeding to login",
);
onSelect({
url,
enabledOAuthProviders:
enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod,
});
} catch (error) {
console.error(
"[ServerSelection] ❌ Unexpected error during connection test:",
error,
);
setTestError(
error instanceof Error
? error.message
: t("setup.server.error.testFailed", "Connection test failed"),
);
} finally {
setTesting(false);
}
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submitServerUrl();
}}
>
<Stack gap="md">
<TextInput
label={t("setup.server.url.label", "Server URL")}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
setCustomUrl(e.target.value);
setTestError(null);
setSecurityDisabled(false);
}}
disabled={loading || testing}
error={testError}
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",
)}
>
<Stack gap="sm">
<Text size="sm">
{t(
"setup.server.error.securityDisabled.body",
"This server does not have login enabled. To connect to this server, you must enable authentication:",
)}
</Text>
<Text size="sm" component="div">
<ol style={{ margin: 0, paddingLeft: "1.5rem" }}>
<li>
{t(
"setup.server.error.securityDisabled.step1",
"Set DOCKER_ENABLE_SECURITY=true in your environment",
)}
</li>
<li>
{t(
"setup.server.error.securityDisabled.step2",
"Or set security.enableLogin=true in settings.yml",
)}
</li>
<li>
{t(
"setup.server.error.securityDisabled.step3",
"Restart the server",
)}
</li>
</ol>
</Text>
</Stack>
</Alert>
)}
{serverUrl && (
<div className="navigation-link-container">
<button
type="button"
className="navigation-link-button"
disabled={testing || loading}
onClick={() => {
setCustomUrl(serverUrl);
// Auto-submit the form after setting the URL
setTimeout(() => {
void submitServerUrl();
}, 0);
}}
>
{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>
</Stack>
</form>
);
};
@@ -0,0 +1,37 @@
import React from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import { ServerSelection } from "@app/components/SetupWizard/ServerSelection";
import { ServerConfig } from "@app/services/connectionModeService";
import "@app/routes/authShared/auth.css";
interface ServerSelectionScreenProps {
onSelect: (config: ServerConfig) => void;
loading: boolean;
error: string | null;
}
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",
)}
/>
<ErrorMessage error={error} />
<ServerSelection onSelect={onSelect} loading={loading} />
</>
);
};
@@ -0,0 +1,96 @@
/* Desktop-specific OAuth button styles for self-hosted server connections */
/* These styles are isolated from the web SSO buttons to prevent conflicts */
.oauth-container-vertical-desktop {
display: flex;
flex-direction: column;
gap: 0.875rem; /* 14px */
align-items: stretch;
}
.oauth-button-vertical-desktop {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0.75rem 1rem; /* 12px 16px */
border: 1px solid #d1d5db;
border-radius: 0.75rem; /* 12px */
background-color: var(--auth-card-bg-light-only);
font-size: 1rem; /* 16px */
font-weight: 500;
color: var(--auth-text-primary-light-only);
cursor: pointer;
gap: 0.75rem; /* 12px */
font-family: inherit;
transition: background-color 0.2s ease;
}
.oauth-button-vertical-desktop:hover:not(:disabled) {
background-color: #f3f4f6;
}
.oauth-button-vertical-desktop:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.oauth-button-vertical-desktop:focus-visible {
outline: 2px solid var(--auth-border-focus-light-only);
outline-offset: 2px;
}
/* Fix Mantine Button internal spans */
.oauth-button-vertical-desktop .mantine-Button-inner {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
}
.oauth-button-vertical-desktop .mantine-Button-label {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.75rem;
overflow: visible;
}
.oauth-button-left-desktop {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1 1 auto;
}
.oauth-button-text-desktop {
font-size: 1rem;
font-weight: 500;
color: inherit;
line-height: 1.2;
display: inline-flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oauth-icon-wrapper-desktop {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
}
.oauth-icon-tiny-desktop {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
display: block;
flex-shrink: 0;
}
@@ -0,0 +1,585 @@
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Text, Button, Alert, Loader, Center } from "@mantine/core";
import { DesktopAuthLayout } from "@app/components/SetupWizard/DesktopAuthLayout";
import { SaaSLoginScreen } from "@app/components/SetupWizard/SaaSLoginScreen";
import { SaaSSignupScreen } from "@app/components/SetupWizard/SaaSSignupScreen";
import { ServerSelectionScreen } from "@app/components/SetupWizard/ServerSelectionScreen";
import { SelfHostedLoginScreen } from "@app/components/SetupWizard/SelfHostedLoginScreen";
import {
ServerConfig,
SSOProviderConfig,
connectionModeService,
} from "@app/services/connectionModeService";
import {
AuthServiceError,
authService,
UserInfo,
} from "@app/services/authService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { listen } from "@tauri-apps/api/event";
import "@app/routes/authShared/auth.css";
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
enum SetupStep {
SaaSLogin,
SaaSSignup,
ServerSelection,
SelfHostedLogin,
}
interface SetupWizardProps {
onComplete: () => void;
/** Omit the DesktopAuthLayout wrapper — use when rendering inside a modal */
noLayout?: boolean;
/** Called when the user dismisses the wizard (modal close button) */
onClose?: () => void;
}
export const SetupWizard: React.FC<SetupWizardProps> = ({
onComplete,
noLayout = false,
onClose,
}) => {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({
url: STIRLING_SAAS_URL,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState("");
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
const [lockConnectionMode, setLockConnectionMode] = useState(false);
const [lockedServerUnreachable, setLockedServerUnreachable] = useState(false);
const [lockedServerChecking, setLockedServerChecking] = useState(false);
const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError("No SaaS server configured");
return;
}
try {
setLoading(true);
setError(null);
// Only attempt password login if a password is provided
// If password is empty, assume OAuth login already completed
const isAlreadyAuthenticated = await authService.isAuthenticated();
if (!isAlreadyAuthenticated && password) {
await authService.login(serverConfig.url, username, password);
}
await connectionModeService.switchToSaaS(serverConfig.url);
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error("SaaS login failed:", err);
setError(err instanceof Error ? err.message : "SaaS login failed");
setLoading(false);
}
};
const handleSaaSLoginOAuth = async (_userInfo: UserInfo) => {
if (!serverConfig) {
setError("No SaaS server configured");
return;
}
try {
setLoading(true);
setError(null);
// OAuth already completed by authService.loginWithOAuth
await connectionModeService.switchToSaaS(serverConfig.url);
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error("SaaS OAuth login completion failed:", err);
setError(
err instanceof Error ? err.message : "Failed to complete SaaS login",
);
setLoading(false);
}
};
const handleLocalMode = async () => {
try {
setLoading(true);
setError(null);
// Save the server URL so it pre-fills on reconnect
if (serverConfig?.url) {
localStorage.setItem("server_url", serverConfig.url);
}
await connectionModeService.switchToLocal();
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error("Failed to continue in local mode:", err);
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
};
const handleSelfHostedClick = () => {
if (lockConnectionMode) {
return;
}
setError(null);
setActiveStep(SetupStep.ServerSelection);
};
const handleSwitchToSignup = () => {
setError(null);
setActiveStep(SetupStep.SaaSSignup);
};
const handleSwitchToLogin = () => {
setError(null);
setActiveStep(SetupStep.SaaSLogin);
};
const handleServerSelection = (config: ServerConfig) => {
console.log("[SetupWizard] Server selected:", config);
console.log("[SetupWizard] OAuth providers:", config.enabledOAuthProviders);
console.log("[SetupWizard] Login method:", config.loginMethod);
setServerConfig(config);
setError(null);
setSelfHostedMfaCode("");
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.SelfHostedLogin);
};
const handleSelfHostedLogin = async (username: string, password: string) => {
console.log("[SetupWizard] 🔐 Starting self-hosted login");
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
console.log(`[SetupWizard] Username: ${username}`);
if (!serverConfig) {
console.error("[SetupWizard] ❌ No server configured");
setError("No server configured");
return;
}
try {
setLoading(true);
setError(null);
console.log("[SetupWizard] Step 1: Authenticating with server...");
const trimmedMfa = selfHostedMfaCode.trim();
const mfaCode = trimmedMfa ? trimmedMfa : undefined;
await authService.login(serverConfig.url, username, password, mfaCode);
console.log("[SetupWizard] ✅ Authentication successful");
setSelfHostedMfaRequired(false);
setSelfHostedMfaCode("");
console.log("[SetupWizard] Step 2: Switching to self-hosted mode...");
await connectionModeService.switchToSelfHosted(serverConfig);
console.log("[SetupWizard] ✅ Switched to self-hosted mode");
console.log("[SetupWizard] Step 3: Initializing external backend...");
await tauriBackendService.initializeExternalBackend();
console.log("[SetupWizard] ✅ External backend initialized");
console.log("[SetupWizard] ✅ Setup complete, calling onComplete()");
onComplete();
} catch (err) {
console.error("[SetupWizard] ❌ Self-hosted login failed:", err);
let errorMessage = "Self-hosted login failed";
if (err instanceof AuthServiceError) {
if (err.code === "mfa_required" || err.code === "invalid_mfa_code") {
setSelfHostedMfaRequired(true);
}
errorMessage = err.message;
} else if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === "string") {
errorMessage = err;
}
if (
errorMessage.toLowerCase().includes("mfa_required") ||
errorMessage.toLowerCase().includes("invalid_mfa_code")
) {
setSelfHostedMfaRequired(true);
}
console.error("[SetupWizard] Error message:", errorMessage);
setError(errorMessage);
setLoading(false);
}
};
const handleSelfHostedOAuthSuccess = async (_userInfo: UserInfo) => {
console.log("[SetupWizard] 🔐 OAuth login successful, completing setup");
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
if (!serverConfig) {
console.error("[SetupWizard] ❌ No server configured");
setError("No server configured");
return;
}
try {
setLoading(true);
setError(null);
console.log("[SetupWizard] Step 1: OAuth already completed");
console.log("[SetupWizard] Step 2: Switching to self-hosted mode...");
await connectionModeService.switchToSelfHosted(serverConfig);
console.log("[SetupWizard] ✅ Switched to self-hosted mode");
console.log("[SetupWizard] Step 3: Initializing external backend...");
await tauriBackendService.initializeExternalBackend();
console.log("[SetupWizard] ✅ External backend initialized");
console.log("[SetupWizard] ✅ Setup complete, calling onComplete()");
onComplete();
} catch (err) {
console.error(
"[SetupWizard] ❌ Self-hosted OAuth login completion failed:",
err,
);
const errorMessage =
err instanceof Error ? err.message : "Failed to complete login";
console.error("[SetupWizard] Error message:", errorMessage);
setError(errorMessage);
setLoading(false);
}
};
useEffect(() => {
const unsubscribePromise = listen<string>("deep-link", async (event) => {
const url = event.payload;
if (!url) return;
try {
const parsed = new URL(url);
// Supabase sends tokens in the URL hash
const hash = parsed.hash.replace(/^#/, "");
const params = new URLSearchParams(hash);
const accessToken = params.get("access_token");
const type = params.get("type") || parsed.searchParams.get("type");
// Self-hosted SSO deep links are normally handled by authService.loginWithSelfHostedOAuth.
// Fallback here only if no in-flight auth listener exists (e.g. renderer reload mid-flow).
if (type === "sso" || type === "sso-selfhosted") {
if (authService.isSelfHostedDeepLinkFlowActive()) {
return;
}
const accessTokenFromHash = params.get("access_token");
const accessTokenFromQuery = parsed.searchParams.get("access_token");
const serverFromQuery = parsed.searchParams.get("server");
const token = accessTokenFromHash || accessTokenFromQuery;
const serverUrl =
serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
if (!token || !serverUrl) {
console.error(
"[SetupWizard] Deep link missing token or server for SSO completion",
);
return;
}
setLoading(true);
setError(null);
await authService.completeSelfHostedSession(serverUrl, token);
await connectionModeService.switchToSelfHosted({ url: serverUrl });
await tauriBackendService.initializeExternalBackend();
onComplete();
return;
}
if (
!type ||
(type !== "signup" && type !== "recovery" && type !== "magiclink")
) {
return;
}
if (!accessToken) {
console.error("[SetupWizard] Deep link missing access_token");
return;
}
setLoading(true);
setError(null);
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",
);
setLoading(false);
}
});
return () => {
void unsubscribePromise.then((unsub) => unsub());
};
}, [onComplete, serverConfig?.url]);
const handleBack = () => {
if (lockConnectionMode) {
return;
}
setError(null);
if (activeStep === SetupStep.SelfHostedLogin) {
setSelfHostedMfaCode("");
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.SaaSLogin);
setServerConfig({ url: STIRLING_SAAS_URL });
} else if (activeStep === SetupStep.SaaSSignup) {
setActiveStep(SetupStep.SaaSLogin);
}
};
const loadLockedConfig = useCallback(async () => {
const currentConfig = await connectionModeService.getCurrentConfig();
if (!currentConfig.lock_connection_mode) return;
const serverUrl = currentConfig.server_config?.url;
if (!serverUrl) return;
setLockConnectionMode(true);
setLockedServerUnreachable(false);
setLockedServerChecking(true);
const savedUrl = serverUrl.replace(/\/+$/, "");
let updatedConfig: ServerConfig = {
...(currentConfig.server_config ?? { url: savedUrl }),
};
try {
const response = await fetch(
`${savedUrl}/api/v1/proprietary/ui-data/login`,
);
if (response.ok) {
const data = await response.json();
const enabledProviders: SSOProviderConfig[] = [];
const providerEntries = Object.entries(data.providerList || {});
providerEntries.forEach(([path, label]) => {
const id = path.split("/").pop();
if (id) {
enabledProviders.push({
id,
path,
label: typeof label === "string" ? label : undefined,
});
}
});
updatedConfig = {
...updatedConfig,
enabledOAuthProviders:
enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod: data.loginMethod || "all",
};
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setActiveStep(SetupStep.SelfHostedLogin);
} else {
// Server responded but with an error — still show login form
updatedConfig = { ...updatedConfig, loginMethod: "all" };
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setActiveStep(SetupStep.SelfHostedLogin);
}
} catch (err) {
// Network error — server is unreachable
console.error("[SetupWizard] Server unreachable:", err);
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setLockedServerUnreachable(true);
setActiveStep(SetupStep.SelfHostedLogin);
}
}, []);
useEffect(() => {
void loadLockedConfig();
}, [loadLockedConfig]);
const wizardContent = (
<>
{/* Step Content */}
{!lockConnectionMode && activeStep === SetupStep.SaaSLogin && (
<SaaSLoginScreen
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
onLogin={handleSaaSLogin}
onOAuthSuccess={handleSaaSLoginOAuth}
onSelfHostedClick={handleSelfHostedClick}
onSwitchToSignup={handleSwitchToSignup}
onSkipSignIn={handleLocalMode}
onClose={onClose}
loading={loading}
error={error}
/>
)}
{!lockConnectionMode && activeStep === SetupStep.SaaSSignup && (
<SaaSSignupScreen
loading={loading}
error={error}
onLogin={handleSaaSLogin}
onSwitchToLogin={handleSwitchToLogin}
/>
)}
{!lockConnectionMode && activeStep === SetupStep.ServerSelection && (
<ServerSelectionScreen
onSelect={handleServerSelection}
loading={loading}
error={error}
/>
)}
{lockConnectionMode && lockedServerChecking && (
<Center py="xl">
<Loader size="md" />
</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",
)}
>
{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>
</>
)}
{/* Back Button */}
{!lockConnectionMode && activeStep > SetupStep.SaaSLogin && !loading && (
<div
className="navigation-link-container"
style={{ marginTop: "1.5rem" }}
>
<button
type="button"
onClick={handleBack}
className="navigation-link-button"
>
{t("common.back", "Back")}
</button>
</div>
)}
</>
);
if (noLayout) {
return <div style={{ padding: "2rem" }}>{wizardContent}</div>;
}
return <DesktopAuthLayout>{wizardContent}</DesktopAuthLayout>;
};
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { Modal } from "@mantine/core";
import { SetupWizard } from "@app/components/SetupWizard";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex";
export function SignInModal() {
const [opened, setOpened] = useState(false);
const [locked, setLocked] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setLocked(detail?.locked === true);
setOpened(true);
};
window.addEventListener(OPEN_SIGN_IN_EVENT, handler);
return () => window.removeEventListener(OPEN_SIGN_IN_EVENT, handler);
}, []);
if (!opened) return null;
return (
<Modal
opened={opened}
onClose={() => {
if (!locked) setOpened(false);
}}
size={520}
centered
withCloseButton={false}
closeOnClickOutside={!locked}
closeOnEscape={!locked}
padding={0}
radius="lg"
zIndex={Z_INDEX_SIGN_IN_MODAL}
>
<SetupWizard
noLayout
onClose={() => setOpened(false)}
onComplete={() => {
setOpened(false);
// No reload needed — AppProviders remounts the SaaS provider tree via
// connectionModeService subscription when mode changes.
}}
/>
</Modal>
);
}
@@ -0,0 +1,36 @@
import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StirlingFileStub } from "@app/types/fileContext";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
interface FileEditorStatusDotProps {
file: StirlingFileStub;
}
export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) {
const { t } = useTranslation();
const label = !file.localFilePath
? t("fileNotSavedToDisk", "Not saved to disk")
: file.isDirty
? t("unsavedChanges", "Unsaved changes")
: t("fileSavedToDisk", "Saved to disk");
const color = !file.localFilePath
? "var(--mantine-color-red-6)"
: file.isDirty
? "var(--mantine-color-yellow-6)"
: "var(--mantine-color-green-6)";
return (
<div className={styles.thumbBadgesRight}>
<Tooltip label={label}>
<span
className={styles.statusDot}
style={{ backgroundColor: color }}
aria-label={label}
/>
</Tooltip>
</div>
);
}
@@ -0,0 +1,37 @@
/**
* Desktop Override: Onboarding Configuration
*
* This version modifies the onboarding config for the desktop app:
* - Sets isDesktopApp to true in the default runtime state
* - This causes desktop-install step to be skipped
*
* All other step definitions and logic remain the same.
*/
// Re-export everything from core
export {
ONBOARDING_STEPS,
getStepById,
getStepIndex,
} from "@core/components/onboarding/orchestrator/onboardingConfig";
export type {
OnboardingStepId,
OnboardingStepType,
OnboardingStep,
OnboardingRuntimeState,
OnboardingConditionContext,
} from "@core/components/onboarding/orchestrator/onboardingConfig";
// Import and override the default runtime state
import { DEFAULT_RUNTIME_STATE as CORE_DEFAULT_RUNTIME_STATE } from "@core/components/onboarding/orchestrator/onboardingConfig";
import type { OnboardingRuntimeState } from "@core/components/onboarding/orchestrator/onboardingConfig";
/**
* Desktop default runtime state
* Sets isDesktopApp to true so desktop-install step is skipped
*/
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
...CORE_DEFAULT_RUNTIME_STATE,
isDesktopApp: true,
};
@@ -0,0 +1,24 @@
/**
* Desktop Override: Onboarding Orchestrator Hook
*
* Simply wraps the core hook with the desktop-specific default runtime state
* which has isDesktopApp set to true.
*/
import {
useOnboardingOrchestrator as useCoreOnboardingOrchestrator,
type UseOnboardingOrchestratorResult,
} from "@core/components/onboarding/orchestrator/useOnboardingOrchestrator";
import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig";
export type {
OnboardingOrchestratorState,
OnboardingOrchestratorActions,
UseOnboardingOrchestratorResult,
} from "@core/components/onboarding/orchestrator/useOnboardingOrchestrator";
export function useOnboardingOrchestrator(): UseOnboardingOrchestratorResult {
return useCoreOnboardingOrchestrator({
defaultRuntimeState: DEFAULT_RUNTIME_STATE,
});
}
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { Box, Text, Stack } from "@mantine/core";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { BILLING_CONFIG } from "@app/config/billing";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
/**
* Desktop credit counter displayed in QuickAccessBar footer
* Shows when user is in SaaS mode with low credits (<20)
*/
interface QuickAccessBarFooterExtensionsProps {
className?: string;
}
export function QuickAccessBarFooterExtensions({
className,
}: QuickAccessBarFooterExtensionsProps) {
const { creditBalance, loading, isManagedTeamMember } = useSaaSBilling();
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Check connection mode and authentication status
useEffect(() => {
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === "saas");
setIsAuthenticated(auth);
};
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(checkMode);
return unsubscribe;
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
// Don't show credit counter if:
// - Not in SaaS mode
// - Not authenticated
// - Still loading billing data
// - User is a managed team member (unlimited credits)
// - Credits >= 20 (only show when low)
if (
!isSaasMode ||
!isAuthenticated ||
loading ||
isManagedTeamMember ||
creditBalance >= BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD
) {
return null;
}
const handleClick = () => {
// Dispatch low credits event to open upgrade modal
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: { source: "quickAccessBar" },
}),
);
};
return (
<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"}
</Text>
<Text size="xs" c="dimmed" style={{ textDecoration: "underline" }}>
Upgrade
</Text>
</Stack>
</Box>
);
}
@@ -0,0 +1,35 @@
import { Badge, Tooltip } from "@mantine/core";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import { useTranslation } from "react-i18next";
interface CloudBadgeProps {
className?: string;
}
/**
* Badge component to indicate that a tool uses cloud/SaaS backend processing
* Displayed on tool cards when the tool will be routed to the SaaS backend
* instead of the local bundled backend.
*/
export function CloudBadge({ className }: CloudBadgeProps) {
const { t } = useTranslation();
return (
<Tooltip
label={t(
"cloudBadge.tooltip",
"This operation will use your cloud credits",
)}
position="top"
withArrow
>
<Badge
className={className}
leftSection={<CloudOutlinedIcon sx={{ fontSize: 12 }} />}
variant="light"
color="blue"
size="xs"
></Badge>
</Tooltip>
);
}
@@ -0,0 +1,30 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppBanner: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
const [dismissed, setDismissed] = useState(false);
const handleDismissPrompt = () => {
setDismissed(true);
};
return (
<InfoBanner
icon="picture-as-pdf-rounded"
message={t(
"defaultApp.prompt.message",
"Make Stirling PDF your default application for opening PDF files.",
)}
buttonText={t("defaultApp.setDefault", "Set Default")}
buttonIcon="check-circle-rounded"
onButtonClick={handleSetDefault}
onDismiss={handleDismissPrompt}
loading={isLoading}
show={!dismissed && isDefault === false}
/>
);
};
@@ -0,0 +1,38 @@
.locked-button {
width: 100%;
text-align: center;
cursor: not-allowed;
user-select: none;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-md);
background-color: var(--mantine-color-blue-light);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
}
.locked-button-tooltip {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: calc(100% + 8px);
color: white;
white-space: nowrap;
pointer-events: none;
z-index: var(--mantine-z-index-popover);
box-shadow: var(--mantine-shadow-lg);
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-xs);
background-color: var(--mantine-color-dark-7);
padding: 6px var(--mantine-spacing-xs);
}
.locked-button-tooltip-arrow {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid var(--mantine-color-dark-7);
border-left-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
}
@@ -0,0 +1,44 @@
import React from "react";
import "@app/components/shared/DisabledButtonWithTooltip.css";
interface DisabledButtonWithTooltipProps {
/** Tooltip text shown on hover */
tooltip: string;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* A visually disabled button that still responds to hover (showing a tooltip).
* 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) {
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}
>
{children}
</div>
{hovered && (
<div className="locked-button-tooltip">
{tooltip}
<div className="locked-button-tooltip-arrow" />
</div>
)}
</div>
);
}
@@ -0,0 +1,307 @@
import { useState, useEffect, useMemo } from "react";
import {
Paper,
Group,
Text,
ActionIcon,
UnstyledButton,
Popover,
List,
ScrollArea,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus";
import {
selfHostedServerMonitor,
type SelfHostedServerState,
} from "@app/services/selfHostedServerMonitor";
import {
connectionModeService,
type ConnectionMode,
} from "@app/services/connectionModeService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import {
EXTENSION_TO_ENDPOINT,
ENDPOINT_I18N,
} from "@app/constants/convertConstants";
import { ENDPOINTS as SPLIT_ENDPOINTS } from "@app/constants/splitConstants";
import type { ToolId } from "@app/types/toolId";
const BANNER_BG = "var(--mantine-color-gray-1)";
const BANNER_BORDER = "var(--mantine-color-gray-3)";
const BANNER_TEXT = "var(--mantine-color-gray-7)";
const BANNER_ICON = "var(--mantine-color-gray-5)";
const BANNER_LINK = "var(--mantine-color-gray-6)";
/** Maps split endpoint → [i18n key, English fallback] for the method name */
const SPLIT_ENDPOINT_I18N: Record<string, [string, string]> = {
"split-pages": ["split.methods.byPages.name", "Pages"],
"split-pdf-by-sections": ["split.methods.bySections.name", "Sections"],
"split-by-size-or-count": ["split.methods.bySize.name", "File Size"],
"split-pdf-by-chapters": ["split.methods.byChapters.name", "Chapters"],
"auto-split-pdf": ["split.methods.byPageDivider.name", "Page Divider"],
"split-for-poster-print": ["split.methods.byPoster.name", "Printable Chunks"],
};
/**
* Desktop-only banner shown when the user is in self-hosted mode and the
* configured Stirling-PDF server is unreachable.
*
* - Warns the user their server is offline
* - Explains whether local fallback is active
* - Shows an expandable list of tools that are unavailable locally
* - Session-dismissable (reappears on next launch if server still offline)
*/
export function SelfHostedOfflineBanner() {
const { t } = useTranslation();
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(),
);
// Load connection mode and keep it live via subscription
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges((config) =>
setConnectionMode(config.mode),
);
}, []);
// Subscribe to self-hosted server status changes
useEffect(() => {
const unsub = selfHostedServerMonitor.subscribe((state) => {
setServerState(state);
// Auto-collapse tool list when server comes back online
if (state.isOnline) setExpanded(false);
});
return unsub;
}, []);
// React to local backend port being discovered
useEffect(() => {
return tauriBackendService.subscribeToStatus(() => {
setLocalBackendReady(!!tauriBackendService.getBackendUrl());
});
}, []);
// Re-use the toolAvailability already computed by useToolManagement —
// tools with reason 'selfHostedOffline' are the ones unavailable locally.
const { toolAvailability, toolRegistry } = useToolWorkflow();
// Re-use conversion availability already computed by useConversionCloudStatus.
const { availability: conversionAvailability } = useConversionCloudStatus();
const [splitAvailability, setSplitAvailability] = useState<
Record<string, boolean>
>({});
useEffect(() => {
if (serverState.status !== "offline") {
setSplitAvailability({});
return;
}
const localUrl = tauriBackendService.getBackendUrl();
if (!localUrl) {
setSplitAvailability({});
return;
}
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),
})),
).then((results) => {
const map: Record<string, boolean> = {};
for (const { ep, supported } of results) map[ep] = supported;
setSplitAvailability(map);
});
}, [serverState.status]);
const allUnavailableNames = useMemo(() => {
// Top-level tools unavailable in self-hosted offline mode
const toolNames = (Object.keys(toolAvailability) as ToolId[])
.filter(
(id) =>
toolAvailability[id]?.available === false &&
toolAvailability[id]?.reason === "selfHostedOffline",
)
.map((id) => toolRegistry[id]?.name ?? id)
.filter(Boolean);
// Use translated tool names from the registry as prefixes
const convertPrefix = toolRegistry["convert" as ToolId]?.name ?? "Convert";
const splitPrefix = toolRegistry["split" as ToolId]?.name ?? "Split";
// Conversion types unavailable locally — deduplicated by endpoint
const unavailableEndpoints = new Set<string>();
for (const [key, available] of Object.entries(conversionAvailability)) {
if (!available) {
const dashIdx = key.indexOf("-");
const fromExt = key.slice(0, dashIdx);
const toExt = key.slice(dashIdx + 1);
const endpoint = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (endpoint) unavailableEndpoints.add(endpoint);
}
}
const conversionNames = [...unavailableEndpoints]
.map((ep) => {
const i18n = ENDPOINT_I18N[ep];
const suffix = i18n ? (i18n[0] ? t(i18n[0], i18n[1]) : i18n[1]) : ep;
return `${convertPrefix}: ${suffix}`;
})
.filter(Boolean);
// Split methods unavailable locally
const unavailableSplitNames = Object.entries(splitAvailability)
.filter(([, available]) => !available)
.map(([ep]) => {
const i18n = SPLIT_ENDPOINT_I18N[ep];
const suffix = i18n ? t(i18n[0], i18n[1]) : ep;
return `${splitPrefix}: ${suffix}`;
})
.filter(Boolean);
return [...toolNames, ...conversionNames, ...unavailableSplitNames].sort();
}, [
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";
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.",
);
return (
<Paper
radius={0}
style={{
background: BANNER_BG,
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 }}
>
{t("selfHosted.offline.title", "Server unreachable")}
</Text>
<Text
size="xs"
style={{
color: BANNER_TEXT,
opacity: 0.8,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{messageText}
</Text>
</Group>
{allUnavailableNames.length > 0 && (
<Popover
opened={expanded}
onClose={() => setExpanded(false)}
position="bottom-end"
withinPortal
shadow="md"
width={260}
>
<Popover.Target>
<UnstyledButton
onClick={() => setExpanded((e) => !e)}
style={{
color: BANNER_LINK,
fontSize: "var(--mantine-font-size-xs)",
fontWeight: 500,
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
{expanded
? t(
"selfHosted.offline.hideTools",
"Hide unavailable tools ▴",
)
: t(
"selfHosted.offline.showTools",
"View unavailable tools ▾",
)}
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p="xs">
<ScrollArea.Autosize mah={300}>
<List size="xs" spacing={2}>
{allUnavailableNames.map((name) => (
<List.Item key={name}>{name}</List.Item>
))}
</List>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
)}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => setDismissed(true)}
aria-label={t("close", "Close")}
style={{ color: BANNER_TEXT }}
>
<LocalIcon icon="close-rounded" width="0.8rem" height="0.8rem" />
</ActionIcon>
</Group>
</Paper>
);
}
@@ -0,0 +1,161 @@
import { useState, useEffect } from "react";
import { Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { connectionModeService } from "@app/services/connectionModeService";
export function TeamInvitationBanner() {
const { t } = useTranslation();
const { receivedInvitations, acceptInvitation, rejectInvitation } =
useSaaSTeam();
const { refreshBilling } = useSaaSBilling();
const [processing, setProcessing] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [connectionMode, setConnectionMode] = useState<string | null>(null);
// Load connection mode on mount
useEffect(() => {
connectionModeService
.getCurrentMode()
.then((mode) => setConnectionMode(mode));
}, []);
// Accept invitation handler
const handleAccept = async () => {
const invitation = receivedInvitations[0];
if (!invitation) return;
setProcessing(true);
try {
await acceptInvitation(invitation.invitationToken);
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...",
);
await refreshBilling();
setDismissed(true);
} catch (error) {
console.error(
"[TeamInvitationBanner] Failed to accept invitation:",
error,
);
} finally {
setProcessing(false);
}
};
// Reject invitation handler
const handleReject = async () => {
const invitation = receivedInvitations[0];
if (!invitation) return;
setProcessing(true);
try {
await rejectInvitation(invitation.invitationToken);
console.log("[TeamInvitationBanner] Invitation rejected");
setDismissed(true);
} catch (error) {
console.error(
"[TeamInvitationBanner] Failed to reject invitation:",
error,
);
} finally {
setProcessing(false);
}
};
// Visibility logic
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")}{" "}
<strong>{invitation.teamName}</strong>
</Text>
);
const actionButtons = (
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
color="gray"
size="xs"
onClick={handleAccept}
loading={processing}
leftSection={
<LocalIcon
icon="check"
width="0.9rem"
height="0.9rem"
style={{ color: "var(--mantine-color-dark-9)" }}
/>
}
styles={{
label: {
color: "var(--mantine-color-dark-9)",
},
}}
>
{t("team.invitationBanner.acceptButton", "Accept")}
</Button>
<Button
variant="subtle"
size="xs"
onClick={handleReject}
loading={processing}
style={{ color: "rgba(255, 255, 255, 0.7)" }}
>
{t("team.invitationBanner.rejectButton", "Decline")}
</Button>
</Group>
);
return (
<InfoBanner
icon="mail"
message={
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{ width: "100%" }}
>
{message}
{actionButtons}
</Group>
}
show={shouldShow}
dismissible={false}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
/>
);
}
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from "react";
import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { saasBillingService } from "@app/services/saasBillingService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import OpenInBrowserIcon from "@mui/icons-material/OpenInBrowser";
type CheckoutState = {
status: "idle" | "loading" | "opened" | "refreshing" | "error";
error?: string;
sessionPlanId?: string;
};
interface SaaSStripeCheckoutProps {
opened: boolean;
onClose: () => void;
planId: string | null;
onSuccess?: () => void;
}
export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
opened,
onClose,
planId,
onSuccess,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: "idle" });
const createCheckoutSession = async () => {
if (!planId) {
setState({ status: "error", error: "No plan selected" });
return;
}
try {
setState({ status: "loading" });
// Map UI plan IDs to Stripe plan IDs
const stripePlanId = planId === "team" ? "pro" : planId;
// Open checkout in browser (returns void, opens browser window)
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";
console.error("[SaaSStripeCheckout] Error creating checkout:", err);
setState({
status: "error",
error: errorMessage,
});
}
};
const handleRefreshClick = async () => {
console.log("[SaaSStripeCheckout] User requested refresh after checkout");
setState({ ...state, status: "refreshing" });
// Give Stripe webhooks a moment to process (2-3 seconds)
await new Promise((resolve) => setTimeout(resolve, 2000));
// Trigger the refresh
if (onSuccess) {
await onSuccess();
}
// Close modal after refresh
onClose();
};
const handleClose = () => {
// Reset state to idle to clean up the session
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
onClose();
};
// Initialize checkout when modal opens or plan changes
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;
if (needsNewSession) {
console.log(
"[SaaSStripeCheckout] Opening checkout in browser for plan:",
planId,
);
createCheckoutSession();
}
} else if (!opened) {
// Clean up state when modal closes
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
}
}, [opened, planId]);
const renderContent = () => {
switch (state.status) {
case "loading":
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</div>
);
case "opened":
return (
<Alert
color="blue"
title={t("payment.checkoutOpened", "Checkout Opened in Browser")}
icon={<OpenInBrowserIcon />}
>
<Stack gap="md">
<Text size="sm">
{t(
"payment.checkoutInstructions",
"Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information.",
)}
</Text>
<Button
variant="filled"
color="blue"
onClick={handleRefreshClick}
fullWidth
>
{t(
"payment.refreshBilling",
"I've Completed Payment - Refresh Billing",
)}
</Button>
<Button variant="subtle" onClick={handleClose} fullWidth>
{t("payment.closeLater", "I'll Do This Later")}
</Button>
</Stack>
</Alert>
);
case "error":
return (
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
const getPlanName = () => {
if (planId === "team") return t("plan.team.name", "Team");
if (planId === "enterprise") return t("plan.enterprise.name", "Enterprise");
return t("plan.free.name", "Free");
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<div>
<Text fw={600} size="lg">
{t("payment.upgradeTitle", "Upgrade to {{planName}}", {
planName: getPlanName(),
})}
</Text>
</div>
}
size="md"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
@@ -0,0 +1,193 @@
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import {
useConfigNavSections as useProprietaryConfigNavSections,
createConfigNavSections as createProprietaryConfigNavSections,
} from "@proprietary/components/shared/config/configNavSections";
import { ConfigNavSection } from "@core/components/shared/config/configNavSections";
import { ConnectionSettings } from "@app/components/ConnectionSettings";
import { SaasPlanSection } from "@app/components/shared/config/configSections/SaasPlanSection";
import { SaaSTeamsSection } from "@app/components/shared/config/configSections/SaaSTeamsSection";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
export type {
ConfigNavSection,
ConfigNavItem,
} from "@core/components/shared/config/configNavSections";
/**
* Hook version of desktop config nav sections with proper i18n support
*/
export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false,
onRequestClose: () => void = () => {},
): ConfigNavSection[] => {
const { t } = useTranslation();
const [connectionMode, setConnectionMode] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges((config) =>
setConnectionMode(config.mode),
);
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
const isSaasMode = connectionMode === "saas";
const isLocalMode = connectionMode === "local";
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = useProprietaryConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
onRequestClose,
);
const connectionModeSection: ConfigNavSection = {
title: t("settings.connection.title", "Connection Mode"),
items: [
{
key: "connectionMode",
label: t("settings.connection.title", "Connection Mode"),
icon: "desktop-cloud-rounded",
component: <ConnectionSettings />,
},
],
};
// In local mode only show Preferences + Connection Mode — everything else
// requires a server and will 500 or show irrelevant admin UI.
if (isLocalMode) {
const result: ConfigNavSection[] = [];
if (sections.length > 0) result.push(sections[0]);
result.push(connectionModeSection);
return result;
}
// Identifies self-hosted admin sections by their first item's stable key.
// Using item keys avoids dependency on translated section titles (#17).
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
"people", // Workspace section
"adminGeneral", // Configuration section
"adminSecurity", // Security & Authentication section
"adminPlan", // Licensing & Analytics section
"adminLegal", // Policies & Privacy section
]);
// Build the result array explicitly instead of splice with hardcoded indices (#18).
const result: ConfigNavSection[] = [];
// Preferences is always first
if (sections.length > 0) result.push(sections[0]);
// Connection Mode always sits immediately after Preferences
result.push(connectionModeSection);
// Plan & Billing and Team sections only when authenticated in SaaS mode
if (isSaasMode && isAuthenticated) {
result.push({
title: t("settings.planBilling.title", "Plan & Billing"),
items: [
{
key: "planBilling",
label: t("settings.planBilling.title", "Plan & Billing"),
icon: "credit-card",
component: <SaasPlanSection />,
},
],
});
result.push({
title: t("settings.team.title", "Team"),
items: [
{
key: "teams",
label: t("settings.team.title", "Team"),
icon: "groups-rounded",
component: <SaaSTeamsSection />,
},
],
});
}
// Append remaining proprietary sections, skipping self-hosted admin sections in SaaS mode
// and hiding the Account section when not authenticated.
for (const section of sections.slice(1)) {
const firstItemKey = section.items[0]?.key;
if (
isSaasMode &&
firstItemKey &&
SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)
) {
continue;
}
const filteredItems = isAuthenticated
? section.items
: section.items.filter((item) => item.key !== "account");
if (filteredItems.length === 0) continue;
result.push({ ...section, items: filteredItems });
}
return result;
};
/**
* Deprecated: Use useConfigNavSections hook instead
* Desktop extension of createConfigNavSections that adds connection settings
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn(
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
);
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = createProprietaryConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
);
// Add Connection section at the beginning (after Preferences)
sections.splice(1, 0, {
title: "Connection",
items: [
{
key: "connectionMode",
label: "Connection Mode",
icon: "desktop-cloud-rounded",
component: <ConnectionSettings />,
},
],
});
// Add Plan & Billing section (after Connection Mode)
sections.splice(2, 0, {
title: "Plan & Billing",
items: [
{
key: "planBilling",
label: "Plan & Billing",
icon: "credit-card",
component: <SaasPlanSection />,
},
],
});
return sections;
};
@@ -0,0 +1,46 @@
import React from "react";
import { Paper, Text, Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppSettings: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t("settings.general.defaultPdfEditor", "Default PDF editor")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{isDefault === true
? t(
"settings.general.defaultPdfEditorActive",
"Stirling PDF is your default PDF editor",
)
: isDefault === false
? t(
"settings.general.defaultPdfEditorInactive",
"Another application is set as default",
)
: t("settings.general.defaultPdfEditorChecking", "Checking...")}
</Text>
</div>
<Button
variant={isDefault ? "light" : "filled"}
color="blue"
size="sm"
onClick={handleSetDefault}
loading={isLoading}
disabled={isDefault === true}
>
{isDefault
? t("settings.general.defaultPdfEditorSet", "Already Default")
: t("settings.general.setAsDefault", "Set as Default")}
</Button>
</Group>
</Paper>
);
};
@@ -0,0 +1,18 @@
import React from "react";
import { Stack } from "@mantine/core";
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
*/
const GeneralSection: React.FC = () => {
return (
<Stack gap="lg">
<DefaultAppSettings />
<CoreGeneralSection />
</Stack>
);
};
export default GeneralSection;
@@ -0,0 +1,709 @@
import React, { useState, useEffect } from "react";
import {
Button,
TextInput,
Group,
Text,
Stack,
Alert,
Table,
Badge,
ActionIcon,
Menu,
List,
ThemeIcon,
Modal,
CloseButton,
Anchor,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import apiClient from "@app/services/apiClient";
/**
* Desktop SaaS Teams Section
* Allows team management for users connected to SaaS backend
* CRITICAL: Only shown when in SaaS mode (enforced by navigation)
*/
export function SaaSTeamsSection() {
const { t } = useTranslation();
const {
currentTeam,
teamMembers,
teamInvitations,
isTeamLeader,
isPersonalTeam,
inviteUser,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
} = useSaaSTeam();
// Check Pro status via billing context
const { tier } = useSaaSBilling();
const isPro = tier !== "free";
const [inviteEmail, setInviteEmail] = useState("");
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [featuresModalOpened, setFeaturesModalOpened] = useState(false);
// Team rename state
const [isEditingName, setIsEditingName] = useState(false);
const [newTeamName, setNewTeamName] = useState("");
const [renamingTeam, setRenamingTeam] = useState(false);
// Refresh team data on mount and every 10 seconds
useEffect(() => {
// Refresh immediately on mount
refreshTeams();
// Then refresh every 10 seconds
const interval = setInterval(() => {
refreshTeams();
}, 10000);
return () => clearInterval(interval);
}, []); // Only run on mount/unmount
const navigateToPlan = () => {
window.dispatchEvent(
new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }),
);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
setInviting(true);
setError(null);
setSuccess(null);
try {
await inviteUser(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"),
);
} finally {
setInviting(false);
}
};
const handleRemove = async (memberId: number, memberEmail: string) => {
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"),
);
}
};
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,
}),
);
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(
error.response?.data?.error ||
t("team.cancelInviteError", "Failed to cancel invitation"),
);
}
};
const handleStartRename = () => {
if (currentTeam) {
setNewTeamName(currentTeam.name);
setIsEditingName(true);
}
};
const handleCancelRename = () => {
setIsEditingName(false);
setNewTeamName("");
};
const handleRenameSubmit = async () => {
if (!currentTeam || !newTeamName.trim()) return;
setRenamingTeam(true);
setError(null);
try {
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/rename`, {
newName: newTeamName.trim(),
});
setSuccess(t("team.renameSuccess", "Team renamed successfully"));
setIsEditingName(false);
await refreshTeams();
} catch (err) {
const error = err as {
response?: { data?: { error?: string } };
message?: string;
};
setError(
error.response?.data?.error ||
error.message ||
t("team.renameError", "Failed to rename team"),
);
} finally {
setRenamingTeam(false);
}
};
const handleLeaveTeam = async () => {
if (!currentTeam || isPersonalTeam) return;
const confirmMessage = isTeamLeader
? t(
"team.confirmLeaveLeader",
'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.',
{ name: currentTeam.name },
)
: t("team.confirmLeave", 'Are you sure you want to leave "{{name}}"?', {
name: currentTeam.name,
});
if (!window.confirm(confirmMessage)) return;
try {
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"),
);
}
};
if (!currentTeam) {
return (
<Alert color="gray">
<Text>{t("team.loading", "Loading team information...")}</Text>
</Alert>
);
}
return (
<Stack gap="lg">
{/* Header */}
<div>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
{isEditingName ? (
<Group gap="xs" align="center">
<TextInput
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder={t("team.namePlaceholder", "Team name")}
style={{ flex: 1, maxWidth: 300 }}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") handleCancelRename();
}}
/>
<ActionIcon
variant="filled"
color="blue"
onClick={handleRenameSubmit}
loading={renamingTeam}
disabled={!newTeamName.trim()}
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleCancelRename}
disabled={renamingTeam}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
) : (
<Group gap="xs" align="center">
<Text fw={600} size="lg">
{currentTeam.name}
</Text>
{isTeamLeader && !isPersonalTeam && (
<ActionIcon
variant="subtle"
size="sm"
onClick={handleStartRename}
aria-label={t("team.editName", "Edit team name")}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
)}
{isTeamLeader && (
<Badge color="blue">{t("team.leader", "LEADER")}</Badge>
)}
{isPersonalTeam && (
<Badge color="gray" variant="light" size="xs">
{t("team.personal", "Personal")}
</Badge>
)}
</Group>
)}
{!isEditingName && !isPersonalTeam && (
<Text size="sm" c="dimmed" mt={4}>
{t("team.memberCount", "{{count}} team members", {
count: currentTeam.seatsUsed,
})}
</Text>
)}
</div>
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
<Button
color="red"
variant="outline"
size="xs"
onClick={handleLeaveTeam}
leftSection={
<LocalIcon icon="logout" width="1rem" height="1rem" />
}
>
{t("team.leaveButton", "Leave Team")}
</Button>
)}
</Group>
</div>
{/* Upgrade Banner for Free Users */}
{isPersonalTeam && !isPro && (
<Alert
color="blue"
icon={<LocalIcon icon="info" width={16} height={16} />}
>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t(
"team.upgrade.title",
"Upgrade to Pro to unlock team features",
)}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{t(
"team.upgrade.description",
"Invite members, share credits, and more.",
)}{" "}
<Anchor
size="xs"
onClick={() => setFeaturesModalOpened(true)}
style={{ cursor: "pointer" }}
>
{t("common.learnMore", "Learn more")}
</Anchor>
</Text>
</div>
<Button size="sm" variant="light" onClick={navigateToPlan}>
{t("team.upgrade.button", "Upgrade to Pro")}
</Button>
</Group>
</Alert>
)}
{/* Team Features Modal */}
<Modal
opened={featuresModalOpened}
onClose={() => setFeaturesModalOpened(false)}
size="md"
centered
padding="xl"
withCloseButton={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setFeaturesModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header */}
<Stack gap="md" align="center">
<Badge size="lg" color="violet" variant="filled">
{t("team.features.badge", "PRO FEATURE")}
</Badge>
<Text size="xl" fw={700} ta="center">
{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",
)}
</Text>
</Stack>
{/* Features List */}
<List
spacing="md"
size="sm"
icon={
<ThemeIcon color="violet" size={24} radius="xl" variant="light">
<LocalIcon icon="check" width={14} height={14} />
</ThemeIcon>
}
>
<List.Item>
<Text fw={500}>
{t("team.features.invite.title", "Invite team members")}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.invite.description",
"Add unlimited users with additional seat purchases",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t(
"team.features.credits.title",
"Share credits across your team",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.credits.description",
"Pool resources for collaborative work",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t(
"team.features.dashboard.title",
"Team management dashboard",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.dashboard.description",
"Control permissions, monitor usage, and manage members",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t("team.features.billing.title", "Centralized billing")}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.billing.description",
"One invoice for all team seats and usage",
)}
</Text>
</List.Item>
</List>
{/* CTA Button */}
<Button
size="md"
fullWidth
onClick={() => {
setFeaturesModalOpened(false);
navigateToPlan();
}}
>
{t("team.features.viewPlans", "View Pro Plans")}
</Button>
</Stack>
</div>
</Modal>
{/* Error/Success Messages */}
{error && (
<Alert color="red" onClose={() => setError(null)} withCloseButton>
{error}
</Alert>
)}
{success && (
<Alert color="green" onClose={() => setSuccess(null)} withCloseButton>
{success}
</Alert>
)}
{/* Invite Members (Pro Users) */}
{isTeamLeader && isPro && (
<div>
<Text fw={600} size="md" mb="sm">
{t("team.invite.title", "Invite Team Member")}
</Text>
<form onSubmit={handleInvite}>
<Group>
<TextInput
type="email"
placeholder={t("team.invite.placeholder", "[email protected]")}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
style={{ flex: 1 }}
required
error={
inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
? t("team.invite.invalidEmail", "Invalid email format")
: undefined
}
/>
<Button
type="submit"
loading={inviting}
disabled={
!inviteEmail.trim() ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
}
>
{t("team.invite.sendButton", "Send Invite")}
</Button>
</Group>
</form>
</div>
)}
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">
{t("team.members.title", "Team Members")}
</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr
style={{ backgroundColor: "var(--mantine-color-gray-0)" }}
>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.nameColumn", "Name")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.emailColumn", "Email")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.roleColumn", "Role")}
</Table.Th>
{isTeamLeader && !isPersonalTeam && (
<Table.Th style={{ width: 50 }}></Table.Th>
)}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamMembers.length === 0 && teamInvitations.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
<Text ta="center" c="dimmed" py="xl">
{t("team.members.empty", "No team members yet.")}
</Text>
</Table.Td>
</Table.Tr>
) : (
<>
{/* Active Members */}
{teamMembers.map((member) => (
<Table.Tr key={`member-${member.id}`}>
<Table.Td>
<Text size="sm" fw={500}>
{member.username}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{member.email}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={member.role === "LEADER" ? "blue" : undefined}
style={
member.role !== "LEADER"
? {
backgroundColor: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
}
: undefined
}
>
{member.role}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
{member.role !== "LEADER" && (
<Menu
position="bottom-end"
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Menu.Target>
<ActionIcon variant="subtle">
<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)
}
>
{t("team.members.remove", "Remove from Team")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
)}
</Table.Tr>
))}
{/* Pending Invitations */}
{teamInvitations
.filter((inv) => inv.status === "PENDING")
.map((invitation) => (
<Table.Tr key={`invitation-${invitation.invitationId}`}>
<Table.Td>
<Text size="sm" fw={500} c="dimmed" fs="italic">
{invitation.inviteeEmail.split("@")[0]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{invitation.inviteeEmail}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color="yellow" variant="light">
{t("team.members.pending", "PENDING")}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
onClick={() =>
handleCancelInvitation(
invitation.invitationId,
invitation.inviteeEmail,
)
}
aria-label={t(
"team.invite.cancelLabel",
"Cancel invitation",
)}
>
<LocalIcon
icon="close"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Table.Td>
)}
</Table.Tr>
))}
</>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
);
}
@@ -0,0 +1,260 @@
import { useEffect, useState } from "react";
import {
Stack,
Loader,
Alert,
Button,
Center,
Text,
Flex,
} from "@mantine/core";
import RefreshIcon from "@mui/icons-material/Refresh";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import { useTranslation } from "react-i18next";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSPlans } from "@app/hooks/useSaaSPlans";
import { connectionModeService } from "@app/services/connectionModeService";
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
import { ActiveSubscriptionCard } from "@app/components/shared/config/configSections/plan/ActiveSubscriptionCard";
import { SaaSAvailablePlansSection } from "@app/components/shared/config/configSections/plan/SaaSAvailablePlansSection";
/**
* SaaS Plan & Billing section
* Shows subscription status, billing information, and usage metrics
* Only visible when connected to SaaS
*/
export function SaasPlanSection() {
const { t } = useTranslation();
const [isSaasMode, setIsSaasMode] = useState<boolean | null>(null);
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
// Billing context
const {
subscription,
usage,
tier,
isTrialing,
trialDaysRemaining,
loading,
error,
refreshBilling,
price,
currency,
isManagedTeamMember,
openBillingPortal,
} = useSaaSBilling();
// Team data for ActiveSubscriptionCard
const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam();
// Plans data
const { plans, loading: plansLoading, error: plansError } = useSaaSPlans();
// Check connection mode on mount
useEffect(() => {
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
setIsSaasMode(mode === "saas");
};
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(
async (config) => {
setIsSaasMode(config.mode === "saas");
},
);
return unsubscribe;
}, []);
// Handle "Manage Billing" button click
const handleManageBilling = async () => {
setIsOpeningPortal(true);
try {
// Context handles opening portal and auto-refresh
await openBillingPortal();
} catch (error) {
console.error("[SaasPlanSection] Failed to open billing portal:", error);
} finally {
setIsOpeningPortal(false);
}
};
// Format date for trial end
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
};
// Don't render anything if not in SaaS mode
if (isSaasMode === false) {
return (
<Center p="xl">
<Alert
color="blue"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
>
<Text size="sm">
{t(
"settings.planBilling.notAvailable",
"Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).",
)}
</Text>
</Alert>
</Center>
);
}
// Loading state while checking mode
if (isSaasMode === null) {
return (
<Center p="xl">
<Loader size="sm" />
</Center>
);
}
// Loading state while fetching billing/team data
// Note: loading already includes teamLoading from billing context
if (loading) {
return (
<Center p="xl">
<Stack align="center" gap="md">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t(
"settings.planBilling.loading",
"Loading billing information...",
)}
</Text>
</Stack>
</Center>
);
}
// Error state
if (error) {
return (
<Center p="xl">
<Alert
color="red"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
title={t(
"settings.planBilling.errors.fetchFailed",
"Unable to fetch billing data",
)}
>
<Stack gap="sm">
<Text size="sm">{error}</Text>
<Button
variant="light"
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
onClick={refreshBilling}
size="xs"
>
{t("settings.planBilling.errors.retry", "Retry")}
</Button>
</Stack>
</Alert>
</Center>
);
}
// Main content
return (
<SaaSCheckoutProvider>
<div>
{/* Header with title and Manage Billing button */}
<Flex justify="space-between" align="center" mb="md">
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("settings.planBilling.currentPlan", "Active Plan")}
</h3>
{tier !== "free" && !isManagedTeamMember && (
<Button
variant="light"
size="sm"
onClick={handleManageBilling}
loading={isOpeningPortal}
disabled={isOpeningPortal}
>
{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>
)}
{/* Plan cards */}
<Stack gap="lg">
{/* Current subscription card */}
<ActiveSubscriptionCard
tier={tier}
subscription={subscription}
usage={usage}
isTrialing={isTrialing}
price={price}
currency={currency}
currentTeam={currentTeam}
isTeamLeader={isTeamLeader}
isPersonalTeam={isPersonalTeam}
/>
{/* Available plans grid */}
<SaaSAvailablePlansSection
plans={plans}
currentTier={tier}
loading={plansLoading}
error={plansError}
/>
</Stack>
</div>
</SaaSCheckoutProvider>
);
}
@@ -0,0 +1,257 @@
import {
Card,
Text,
Group,
Badge,
Stack,
Tooltip,
ActionIcon,
} from "@mantine/core";
import GroupIcon from "@mui/icons-material/Group";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { useTranslation } from "react-i18next";
import type { BillingStatus } from "@app/services/saasBillingService";
import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface TeamData {
teamId: number;
name: string;
isPersonal: boolean;
isLeader: boolean;
seatsUsed: number;
}
interface ActiveSubscriptionCardProps {
tier: BillingStatus["tier"];
subscription: BillingStatus["subscription"];
usage: BillingStatus["meterUsage"];
isTrialing: boolean;
price?: number;
currency?: string;
currentTeam?: TeamData | null;
isTeamLeader?: boolean;
isPersonalTeam?: boolean;
}
export function ActiveSubscriptionCard({
tier,
subscription,
usage,
isTrialing,
price,
currency,
currentTeam,
isTeamLeader = false,
isPersonalTeam = true,
}: ActiveSubscriptionCardProps) {
const { t } = useTranslation();
// Format timestamp to readable date
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
};
// Get tier display name
const getTierName = (): string => {
switch (tier) {
case "free":
return t("settings.planBilling.tier.free", "Free Plan");
case "team":
return t("settings.planBilling.tier.team", "Team Plan");
case "enterprise":
return t("settings.planBilling.tier.enterprise", "Enterprise Plan");
default:
return tier;
}
};
// Get price display
const getPriceDisplay = (): string => {
if (tier === "free") {
return "$0/month";
}
// Use actual price from Stripe if available
if (price !== undefined && currency) {
return `${currency}${price}/month`;
}
// Fallback to default pricing
return "$10/month";
};
// Get description
const getDescription = (): string => {
if (tier === "free") {
return t(
"settings.planBilling.tier.freeDescription",
"50 credits per month",
);
}
return t(
"settings.planBilling.tier.teamDescription",
"500 credits/month included, automatic overage billing for uninterrupted service",
);
};
// Format overage cost
const formatOverageCost = (cents: number, credits: number): string => {
return t("settings.planBilling.billing.overageCost", {
amount: `$${(cents / 100).toFixed(2)}`,
credits,
defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`,
});
};
// Pro/Team card
if (tier === "team" || tier === "enterprise") {
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
{/* Left side: Name, badges, description */}
<div style={{ flex: 1 }}>
<Group gap="xs" mb="xs">
<Text size="lg" fw={600}>
{!isPersonalTeam && isTeamLeader
? t("settings.planBilling.tier.team", "Team Plan")
: getTierName()}
</Text>
{!isPersonalTeam && (
<Badge
color="violet"
variant="light"
leftSection={<GroupIcon sx={{ fontSize: 12 }} />}
>
{t("settings.planBilling.tier.teamBadge", "Team")}
</Badge>
)}
<Tooltip
label={
<div style={{ maxWidth: 300 }}>
<Text size="sm" mb="xs">
{t("settings.planBilling.tier.teamTooltipCredits", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`,
})}
</Text>
<Text size="sm" mb="xs">
{t("settings.planBilling.tier.teamTooltipOverage", {
price: getFormattedOveragePrice(),
defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`,
})}
</Text>
<Text size="sm">
{t(
"settings.planBilling.tier.teamTooltipFineprint",
"Only pay for what you use beyond included credits.",
)}
</Text>
</div>
}
multiline
withArrow
position="right"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<InfoOutlinedIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
{isTrialing && (
<Badge color="blue" variant="light">
{t("settings.planBilling.status.trial", "Trial")}
</Badge>
)}
</Group>
{!isPersonalTeam && !isTeamLeader && (
<Text size="sm" c="dimmed" mb="xs">
{t(
"settings.planBilling.team.managedByTeam",
"Managed by team",
)}
</Text>
)}
{!isPersonalTeam && isTeamLeader && currentTeam && (
<Text size="sm" c="dimmed" mb="xs">
{t(
"settings.planBilling.team.memberCount",
"{{count}} team members",
{ count: currentTeam.seatsUsed },
)}
</Text>
)}
<Text size="sm" c="dimmed" mb="xs">
{getDescription()}
</Text>
{/* Show overage cost if applicable */}
{usage && usage.currentPeriodCredits > 0 && (
<Text size="sm" c="orange" fw={500}>
{formatOverageCost(
usage.estimatedCost,
usage.currentPeriodCredits,
)}
</Text>
)}
</div>
{/* Right side: Price */}
<div style={{ textAlign: "right" }}>
{!isPersonalTeam && !isTeamLeader ? (
<Text size="lg" c="dimmed">
{t(
"settings.planBilling.team.managedByTeam",
"Managed by team",
)}
</Text>
) : (
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
)}
</div>
</Group>
{/* Next billing date at bottom */}
{subscription?.currentPeriodEnd && (
<Group gap="xs" mt="xs">
<Text size="sm" c="dimmed">
{t(
"settings.planBilling.billing.nextBillingDate",
"Next billing date:",
)}{" "}
{formatDate(subscription.currentPeriodEnd)}
</Text>
</Group>
)}
</Stack>
</Card>
);
}
// Free plan card
return (
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{getTierName()}
</Text>
</Group>
<Text size="sm" c="dimmed">
{getDescription()}
</Text>
</div>
<div style={{ textAlign: "right" }}>
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
</div>
</Group>
</Card>
);
}
@@ -0,0 +1,102 @@
import React from "react";
import { Card, Text, Button, Stack, List, ThemeIcon } from "@mantine/core";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { useTranslation } from "react-i18next";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { BILLING_CONFIG } from "@app/config/billing";
import type { TierLevel } from "@app/types/billing";
interface PlanUpgradeCardProps {
currentTier: TierLevel;
}
export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
const { t } = useTranslation();
// Don't show upgrade card if already on Team or Enterprise
if (currentTier !== "free") {
return null;
}
const handleUpgrade = async () => {
// For MVP, direct to web SaaS for upgrades
const upgradeUrl = `${STIRLING_SAAS_URL}/account?tab=plan`;
try {
await shellOpen(upgradeUrl);
} catch (error) {
console.error("[PlanUpgradeCard] Failed to open upgrade URL:", error);
}
};
return (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t("settings.planBilling.upgrade.title", "Upgrade Your Plan")}
</Text>
{/* Team plan benefits */}
<Text size="sm" c="dimmed">
{t("settings.planBilling.upgrade.subtitle", "Upgrade to Team for:")}
</Text>
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon color="blue" size={20} radius="xl">
<CheckCircleIcon sx={{ fontSize: 12 }} />
</ThemeIcon>
}
>
<List.Item>
{t("settings.planBilling.upgrade.featureCredits", {
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month (vs ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} on Free)`,
})}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureMembers",
"Unlimited team members",
)}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureThroughput",
"Faster processing throughput",
)}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureApi",
"API access for automation",
)}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureSupport",
"Priority support",
)}
</List.Item>
</List>
{/* Upgrade button */}
<Button variant="filled" fullWidth onClick={handleUpgrade}>
{t("settings.planBilling.upgrade.cta", "Upgrade to Team")}
</Button>
<Text size="xs" c="dimmed" ta="center">
{t(
"settings.planBilling.upgrade.opensInBrowser",
"Opens in browser to complete upgrade",
)}
</Text>
</Stack>
</Card>
);
}
@@ -0,0 +1,79 @@
import React from "react";
import { Text, SimpleGrid, Loader, Alert, Center } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/useSaaSPlans";
import { SaasPlanCard } from "@app/components/shared/config/configSections/plan/SaasPlanCard";
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
import type { TierLevel } from "@app/types/billing";
interface SaaSAvailablePlansSectionProps {
plans: PlanTier[];
currentTier?: TierLevel;
loading?: boolean;
error?: string | null;
}
export const SaaSAvailablePlansSection: React.FC<
SaaSAvailablePlansSectionProps
> = ({ plans, currentTier, loading, error }) => {
const { t } = useTranslation();
const { openCheckout } = useSaaSCheckout();
const handleUpgradeClick = (plan: PlanTier) => {
if (plan.isContactOnly) {
// Handled by mailto link in the card
return;
}
if (plan.id === currentTier) {
// Already on this plan
return;
}
console.log(
"[SaaSAvailablePlansSection] Upgrade clicked for plan:",
plan.id,
);
openCheckout(plan.id);
};
if (loading) {
return (
<Center py="xl">
<Loader size="md" />
</Center>
);
}
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>
</Alert>
);
}
return (
<div>
<Text size="lg" fw={600} mb="md">
{t("plan.availablePlans.title", "Available Plans")}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{plans.map((plan) => (
<SaasPlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentTier}
currentTier={currentTier}
onUpgradeClick={handleUpgradeClick}
/>
))}
</SimpleGrid>
</div>
);
};
@@ -0,0 +1,218 @@
import React from "react";
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/useSaaSPlans";
import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem";
import type { TierLevel } from "@app/types/billing";
interface SaasPlanCardProps {
plan: PlanTier;
isCurrentPlan?: boolean;
currentTier?: TierLevel;
onUpgradeClick?: (plan: PlanTier) => void;
}
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");
// Determine card styling based on plan type
const getCardStyle = () => {
const baseStyle: React.CSSProperties = {
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
borderWidth: 1,
position: "relative",
overflow: "visible",
};
if (plan.id === "free" && isCurrentPlan) {
return {
...baseStyle,
borderColor: "var(--border-default)",
opacity: 0.85,
};
}
if (plan.popular) {
return {
...baseStyle,
borderColor: "rgb(59, 130, 246)",
borderWidth: 2,
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
};
}
return baseStyle;
};
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = "translateY(-4px)";
e.currentTarget.style.boxShadow = "0 12px 48px rgba(59, 130, 246, 0.3)";
}
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 2px 8px rgba(59, 130, 246, 0.1)";
}
};
const handleClick = () => {
if (plan.popular && !isCurrentPlan && onUpgradeClick) {
onUpgradeClick(plan);
}
};
return (
<Card
key={plan.id}
padding="md"
radius="md"
withBorder
style={getCardStyle()}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{plan.popular && (
<Badge
size="sm"
style={{
position: "absolute",
top: -10,
left: "50%",
transform: "translateX(-50%)",
background: "rgb(59, 130, 246)",
color: "white",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.5px",
paddingLeft: "12px",
paddingRight: "12px",
}}
>
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="sm" className="h-full">
<div>
<Text size="md" fw={600} mb="xs">
{plan.name}
</Text>
<Group gap="xs" align="baseline">
<Text size="xl" fw={700}>
{plan.isContactOnly
? t("plan.customPricing", "Custom")
: `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" fw={400} c="dimmed">
{plan.isContactOnly
? t("plan.enterprise.siteLicense", "Site License")
: plan.id === "free"
? `50 ${t("credits.modal.monthlyCredits", "monthly credits")}`
: plan.overagePrice
? `500 ${t("credits.modal.monthlyCredits", "monthly credits")} + ${plan.currency}${plan.overagePrice.toFixed(2)}/${t("credits.modal.overage", "overage")}`
: `500 ${t("credits.modal.monthlyCredits", "monthly credits")}`}
</Text>
</div>
<Stack gap="xs">
<Text size="xs" fw={500} mb="xs">
{plan.id === "free"
? t("credits.modal.forRegularWork", "For regular PDF work:")
: plan.id === "enterprise"
? t(
"credits.modal.everythingInCredits",
"Everything in Credits, plus:",
)
: t(
"credits.modal.everythingInFree",
"Everything in Free, plus:",
)}
</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)"
}
size="xs"
>
{highlight}
</FeatureListItem>
))}
</Stack>
<div className="flex-grow" />
<Button
variant={
isCurrentPlan || isIncluded
? "subtle"
: plan.isContactOnly
? "outline"
: "filled"
}
color={plan.isContactOnly ? undefined : "blue"}
disabled={isCurrentPlan || isIncluded}
fullWidth
size="sm"
radius="lg"
onClick={(e) => {
e.stopPropagation();
onUpgradeClick?.(plan);
}}
style={{
fontWeight: 600,
...((isCurrentPlan || isIncluded) && {
background: "transparent",
border: "none",
cursor: "default",
}),
...(plan.isContactOnly && {
borderColor: "var(--text-primary)",
color: "var(--text-primary)",
}),
}}
component={plan.isContactOnly ? "a" : undefined}
href={
plan.isContactOnly
? `mailto:[email protected]?subject=${plan.name} Plan Inquiry`
: undefined
}
>
{isCurrentPlan
? t("plan.current", "Current Plan")
: isIncluded
? t("plan.included", "Included")
: plan.isContactOnly
? t("plan.contact", "Contact Sales")
: t("plan.upgrade", "Upgrade")}
</Button>
</Stack>
</Card>
);
};
@@ -0,0 +1,127 @@
import React from "react";
import { Card, Text, Stack, Group, Progress, Alert } from "@mantine/core";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { useTranslation } from "react-i18next";
import type { BillingStatus } from "@app/services/saasBillingService";
import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing";
interface UsageDisplayProps {
tier: BillingStatus["tier"];
usage: BillingStatus["meterUsage"];
}
export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
const { t } = useTranslation();
// Credits per month based on tier
const getMonthlyCredits = (): number => {
switch (tier) {
case "free":
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
case "team":
return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH;
case "enterprise":
return 1000; // Placeholder — enterprise credits are custom
default:
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
}
};
const monthlyCredits = getMonthlyCredits();
// Format currency
const formatCurrency = (cents: number): string => {
return `$${(cents / 100).toFixed(2)}`;
};
return (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t("settings.planBilling.credits.title", "Credit Usage")}
</Text>
{/* Monthly credits info */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("settings.planBilling.credits.included", {
count: monthlyCredits,
defaultValue: `${monthlyCredits} credits/month (included)`,
})}
</Text>
</Group>
{/* Overage credits (if metered billing enabled) */}
{usage && usage.currentPeriodCredits > 0 && (
<>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("settings.planBilling.credits.overage", {
count: usage.currentPeriodCredits,
defaultValue: `+ ${usage.currentPeriodCredits} overage`,
})}
</Text>
<Text size="sm" fw={500} c="orange">
{t("settings.planBilling.credits.estimatedCost", {
amount: formatCurrency(usage.estimatedCost),
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
})}
</Text>
</Group>
{/* Progress bar for overage usage */}
<Progress
value={100}
color="orange"
size="sm"
radius="xl"
striped
animated
/>
</Stack>
<Alert
color="blue"
variant="light"
icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}
>
<Text size="xs">
{t("settings.planBilling.credits.overageInfo", {
price: getFormattedOveragePrice(),
defaultValue: `Overage credits are billed at ${getFormattedOveragePrice()} per credit. You'll only pay for what you use beyond your monthly allowance.`,
})}
</Text>
</Alert>
</>
)}
{/* No overage message */}
{(!usage || usage.currentPeriodCredits === 0) && tier !== "free" && (
<Alert color="green" variant="light">
<Text size="sm">
{t("settings.planBilling.credits.noOverage", {
count: monthlyCredits,
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
})}
</Text>
</Alert>
)}
{/* Free tier message */}
{tier === "free" && (
<Alert color="blue" variant="light">
<Text size="sm">
{t("settings.planBilling.credits.freeTierInfo", {
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Free plan includes ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} credits per month. Upgrade to Team for ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month and pay-as-you-go overage billing.`,
})}
</Text>
</Alert>
)}
</Stack>
</Card>
);
}
@@ -0,0 +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 type NavKey = (typeof VALID_NAV_KEYS)[number];
@@ -0,0 +1,573 @@
import {
Modal,
Stack,
Card,
Text,
Group,
Badge,
Button,
Alert,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import {
BILLING_CONFIG,
getCurrencySymbol,
getFormattedOveragePrice,
} from "@app/config/billing";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { CreditUsageBanner } from "@app/components/shared/modals/CreditUsageBanner";
import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem";
import {
FREE_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
ENTERPRISE_PLAN_FEATURES,
} from "@app/config/planFeatures";
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling";
interface CreditExhaustedModalProps {
opened: boolean;
onClose: () => void;
}
/**
* Desktop Credit Exhausted Modal
* 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) {
const { t } = useTranslation();
const { creditBalance, tier, plans, refreshBilling, isManagedTeamMember } =
useSaaSBilling();
const { isTeamLeader } = useSaaSTeam();
const { openCheckout } = useSaaSCheckout();
const { enablingMetering, meteringError, handleEnableMetering } =
useEnableMeteredBilling(refreshBilling, onClose, "CreditExhaustedModal");
// Managed team members have unlimited credits via team
if (isManagedTeamMember) {
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={t("credits.modal.managedMemberTitle", "Unlimited Credits")}
>
<Stack gap="md">
<Text size="sm">
{t(
"credits.modal.managedMemberMessage",
"You have unlimited access to credits through your team. If you need assistance, please contact your team leader.",
)}
</Text>
<Button onClick={onClose} fullWidth>
{t("common.close", "Close")}
</Button>
</Stack>
</Modal>
);
}
// Team users should enable overage billing
// Only team leaders can enable metered billing, members see different UI
if (tier === "team") {
const teamPlan = plans.get("team");
const teamCurrency = teamPlan?.currency ?? "$";
const overagePrice =
teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const formattedOveragePrice = getFormattedOveragePrice(
teamCurrency,
overagePrice,
);
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
closeOnClickOutside={!enablingMetering}
closeOnEscape={!enablingMetering}
centered
size="lg"
radius="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{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.",
)}
</Text>
</Stack>
}
styles={{
body: { padding: "0rem 0rem 0.5rem 0rem" },
content: {
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
header: {
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
overlay: {
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}
/>
{meteringError && (
<Alert color="red" ml="lg" mr="lg">
{meteringError}
</Alert>
)}
{/* Explanation Card */}
<Card
padding="xl"
radius="md"
withBorder
ml="lg"
mr="lg"
style={{
borderColor: "var(--color-primary-600)",
borderWidth: 2,
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md">
<Group gap="xs" align="center">
<TrendingUpIcon
sx={{ fontSize: 24, color: "var(--color-primary-600)" }}
/>
<Text size="lg" fw={600}>
{t(
"credits.modal.meteringTitle",
"Pay-What-You-Use Overage Billing",
)}
</Text>
</Group>
<Text size="sm" c="dimmed">
{t("credits.modal.meteringExplanation", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Your Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working.`,
})}
</Text>
<Stack gap="xs">
<FeatureListItem included>
{t("credits.modal.meteringIncluded", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`,
})}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringPrice",
"Additional credits at {{price}}/credit",
{
price: formattedOveragePrice,
},
)}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringPayAsYouGo",
"Only pay for what you use",
)}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringNoCommitment",
"No commitment, cancel anytime",
)}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringNeverRunOut",
"Never run out of credits",
)}
</FeatureListItem>
</Stack>
<Card
padding="md"
radius="md"
style={{
backgroundColor: "light-dark(#F8F9FA, #1A1A1E)",
border: "1px solid var(--border-subtle)",
}}
>
<Text size="xs" c="dimmed">
{t(
"credits.modal.meteringBillingNote",
"Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.",
)}
</Text>
</Card>
</Stack>
</Card>
{/* Action Buttons */}
<Stack gap="sm" ml="lg" mr="lg">
<Button
onClick={handleEnableMetering}
variant="filled"
color="blue"
fullWidth
size="lg"
loading={enablingMetering}
disabled={!isTeamLeader}
leftSection={<TrendingUpIcon sx={{ fontSize: 18 }} />}
style={{
fontWeight: 600,
}}
>
{t("credits.enableOverageBilling", "Enable Overage Billing")}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t(
"credits.modal.teamLeaderOnly",
"Only team leaders can enable overage billing",
)}
</Text>
)}
<Button
onClick={onClose}
variant="subtle"
fullWidth
size="md"
c="dimmed"
disabled={enablingMetering}
>
{t("credits.maybeLater", "Maybe later")}
</Button>
</Stack>
</Stack>
</Modal>
);
}
// Free tier users - show upgrade modal
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 currencySymbol = getCurrencySymbol(teamCurrency);
const formattedOveragePrice = `${currencySymbol}${overagePrice.toFixed(2)}`;
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
closeOnClickOutside
closeOnEscape
centered
size="60rem"
radius="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{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.",
)}
</Text>
</Stack>
}
styles={{
body: { padding: "0rem 0rem 0.5rem 0rem" },
content: {
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
header: {
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
},
overlay: {
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH}
/>
<Group gap="md" ml="lg" mr="lg" align="stretch" grow>
{/* Free Plan Card */}
<Card
padding="xl"
radius="md"
withBorder
style={{
borderColor: "var(--border-default)",
borderWidth: 1,
opacity: 0.85,
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t("credits.modal.freeTier", "Free Tier")}
</Text>
<Group gap="xs" align="baseline">
<Text size="1.75rem" fw={700}>
{currencySymbol}0
</Text>
<Text size="sm" c="dimmed">
{t("credits.modal.perMonth", "/month")}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.FREE_CREDITS_PER_MONTH}{" "}
{t("credits.modal.monthlyCredits", "monthly credits")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t("credits.modal.forRegularWork", "For regular PDF work:")}
</Text>
{FREE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem
key={index}
included
color="var(--mantine-color-gray-6)"
>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
disabled
variant="subtle"
fullWidth
size="md"
radius="lg"
style={{
fontWeight: 600,
background: "transparent",
border: "none",
cursor: "default",
}}
>
{t("credits.modal.current", "Current Plan")}
</Button>
</Stack>
</Card>
{/* Team Plan Card */}
<Card
padding="lg"
radius="md"
withBorder
style={{
borderColor: "var(--card-selected-border)",
borderWidth: 2,
cursor: "pointer",
transition: "all 0.2s ease",
position: "relative",
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
overflow: "visible",
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
onClick={() => openCheckout("pro")}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-4px)";
e.currentTarget.style.boxShadow =
"0 12px 48px rgba(59, 130, 246, 0.3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow =
"0 2px 8px rgba(59, 130, 246, 0.1)";
}}
>
<Badge
size="sm"
style={{
position: "absolute",
top: -10,
left: "50%",
transform: "translateX(-50%)",
background: "rgb(59, 130, 246)",
color: "white",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.5px",
paddingLeft: "12px",
paddingRight: "12px",
}}
>
{t("credits.modal.popular", "Popular")}
</Badge>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t("credits.modal.teamSubscription", "Team")}
</Text>
<Group gap="xs" align="baseline" mt="xs">
<Text
size="1.75rem"
fw={700}
style={{ color: "var(--text-primary)" }}
>
{currencySymbol}
{teamPrice}
</Text>
<Text size="sm" c="dimmed">
{t("credits.modal.perMonth", "/month")}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}{" "}
{t("credits.modal.monthlyCredits", "monthly credits")} +{" "}
{formattedOveragePrice}/
{t("credits.modal.overage", "overage")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t(
"credits.modal.everythingInFree",
"Everything in Free, plus:",
)}
</Text>
{TEAM_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
onClick={() => openCheckout("pro")}
variant="filled"
color="blue"
fullWidth
size="md"
radius="lg"
style={{
fontWeight: 600,
}}
>
{t("credits.upgrade", "Upgrade")}
</Button>
</Stack>
</Card>
{/* Enterprise Plan Card */}
<Card
padding="lg"
radius="md"
withBorder
style={{
borderWidth: 1,
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="md" fw={600} mb="xs">
{t("credits.modal.enterpriseSubscription", "Enterprise")}
</Text>
<Text size="1.75rem" fw={600}>
{t("credits.modal.customPricing", "Custom")}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t("credits.modal.unlimitedMonthlyCredits", "Site License")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t(
"credits.modal.everythingInCredits",
"Everything in Credits, plus:",
)}
</Text>
{ENTERPRISE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
component="a"
href="mailto:[email protected]?subject=Enterprise Plan Inquiry"
variant="outline"
fullWidth
size="md"
radius="lg"
style={{
borderColor: "var(--text-primary)",
color: "var(--text-primary)",
fontWeight: 600,
}}
>
{t("credits.modal.contactSales", "Contact Sales")}
</Button>
</Stack>
</Card>
</Group>
<Text size="sm" ta="center" c="dimmed" mt="md">
{t("credits.modal.selfHostPrompt", "Want to self host?")}{" "}
<Text
component="a"
href="https://www.stirling.com/pricing"
target="_blank"
rel="noopener noreferrer"
size="sm"
style={{
color: "var(--mantine-color-blue-6)",
textDecoration: "none",
}}
onMouseEnter={(e) => {
e.currentTarget.style.textDecoration = "underline";
}}
onMouseLeave={(e) => {
e.currentTarget.style.textDecoration = "none";
}}
>
{t("credits.modal.selfHostLink", "Review the docs and plans")}
</Text>
</Text>
</Stack>
</Modal>
);
}
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { BILLING_CONFIG } from "@app/config/billing";
import { CreditExhaustedModal } from "@app/components/shared/modals/CreditExhaustedModal";
import { InsufficientCreditsModal } from "@app/components/shared/modals/InsufficientCreditsModal";
import { useCreditEvents } from "@app/hooks/useCreditEvents";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
/**
* Desktop Credit Modal Bootstrap
* Listens to credit events and shows appropriate modals
* Orchestrates credit exhausted and insufficient credits modals
*/
export function CreditModalBootstrap() {
const [exhaustedOpen, setExhaustedOpen] = useState(false);
const [insufficientOpen, setInsufficientOpen] = useState(false);
const [insufficientDetails, setInsufficientDetails] = useState<{
toolId?: string;
requiredCredits?: number;
}>({});
const isSaaSMode = useSaaSMode();
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
// fetched yet (plansLastFetchTime null). This way the modal shows real prices instantly.
useEffect(() => {
if (
isSaaSMode &&
lastFetchTime !== null &&
plansLastFetchTime === null &&
creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD &&
!isManagedTeamMember
) {
refreshPlans();
}
}, [
isSaaSMode,
lastFetchTime,
plansLastFetchTime,
creditBalance,
isManagedTeamMember,
refreshPlans,
]);
// Monitor credit balance and dispatch events
useCreditEvents();
useEffect(() => {
const handleExhausted = () => {
// Don't show modal for managed team members
if (isManagedTeamMember) {
return;
}
setExhaustedOpen(true);
};
const handleInsufficient = (e: Event) => {
// Don't show modal for managed team members
if (isManagedTeamMember) {
return;
}
const customEvent = e as CustomEvent;
setInsufficientDetails({
toolId: customEvent.detail?.operationType,
requiredCredits: customEvent.detail?.requiredCredits,
});
// Show the plans banner (CreditExhaustedModal) instead of the simpler
// InsufficientCreditsModal — same experience as clicking the upgrade button.
setExhaustedOpen(true);
};
window.addEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
window.addEventListener(CREDIT_EVENTS.INSUFFICIENT, handleInsufficient);
return () => {
window.removeEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
window.removeEventListener(
CREDIT_EVENTS.INSUFFICIENT,
handleInsufficient,
);
};
}, [isManagedTeamMember, creditBalance]);
return (
<>
<CreditExhaustedModal
opened={exhaustedOpen && !insufficientOpen}
onClose={() => setExhaustedOpen(false)}
/>
<InsufficientCreditsModal
opened={insufficientOpen}
onClose={() => setInsufficientOpen(false)}
toolId={insufficientDetails.toolId}
requiredCredits={insufficientDetails.requiredCredits}
/>
</>
);
}
@@ -0,0 +1,53 @@
import { Divider, Group, Text, Progress, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface CreditUsageBannerProps {
currentCredits: number;
totalCredits: number;
}
/**
* Credit usage banner showing remaining credits with progress bar
* Used in credit exhausted and upgrade modals
*/
export function CreditUsageBanner({
currentCredits,
totalCredits,
}: CreditUsageBannerProps) {
const { t } = useTranslation();
const percentageRemaining =
totalCredits > 0 ? (currentCredits / totalCredits) * 100 : 0;
return (
<Stack gap="md">
<Divider />
<Stack gap="xs" pr="md" pl="md">
<Group gap="xs" justify="space-between" align="center">
<Text size="md" fw={400} c="dimmed">
{t("credits.modal.creditsThisMonth", "Monthly credits")}
</Text>
<Text size="md" fw={600} style={{ color: "var(--text-primary)" }}>
{t(
"credits.modal.creditsRemaining",
"{{current}} of {{total}} remaining",
{
current: currentCredits,
total: totalCredits,
},
)}
</Text>
</Group>
<Progress
value={percentageRemaining}
size="sm"
radius="xl"
color="blue"
styles={{
root: { backgroundColor: "var(--bg-raised)" },
}}
/>
</Stack>
<Divider />
</Stack>
);
}
@@ -0,0 +1,59 @@
import { Group, Text } from "@mantine/core";
import CheckCircleIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
interface FeatureListItemProps {
children: React.ReactNode;
included: boolean;
color?: string;
dimmed?: boolean;
fw?: number;
size?: "xs" | "sm" | "md" | "lg" | string;
}
export function FeatureListItem({
children,
included,
color = "var(--color-primary-600)",
dimmed = false,
fw = 400,
size = "sm",
}: FeatureListItemProps) {
const Icon = included ? CheckCircleIcon : CloseIcon;
const iconColor = included ? color : "var(--color-red-600)";
// Map Mantine sizes to icon font sizes
const iconSizeMap: Record<string, number> = {
xs: 14,
sm: 16,
md: 18,
lg: 20,
};
// Determine icon size - use mapped value if it exists, otherwise use the string directly
const iconSize = iconSizeMap[size] || size;
// For Text component, only use Mantine sizes if the size is a predefined key
const textSize = iconSizeMap[size] ? size : undefined;
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 }}
>
{children}
</Text>
</Group>
);
}
@@ -0,0 +1,162 @@
import { Modal, Stack, Text, Button, Alert, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
import WarningIcon from "@mui/icons-material/Warning";
import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling";
interface InsufficientCreditsModalProps {
opened: boolean;
onClose: () => void;
toolId?: string;
requiredCredits?: number;
}
/**
* Desktop Insufficient Credits Modal
* Shows when user attempts operation without enough credits
*/
export function InsufficientCreditsModal({
opened,
onClose,
toolId,
requiredCredits,
}: InsufficientCreditsModalProps) {
const { t } = useTranslation();
const { creditBalance, tier, refreshBilling, isManagedTeamMember } =
useSaaSBilling();
const { isTeamLeader } = useSaaSTeam();
const { openCheckout } = useSaaSCheckout();
const { enablingMetering, meteringError, handleEnableMetering } =
useEnableMeteredBilling(
refreshBilling,
onClose,
"InsufficientCreditsModal",
);
const toolName = toolId
? t(`tool.${toolId}.name`, toolId)
: t("common.operation", "this operation");
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Group gap="xs">
<WarningIcon
sx={{ fontSize: 24, color: "var(--mantine-color-orange-6)" }}
/>
<Text size="lg" fw={500}>
{t("credits.insufficient.title", "Insufficient Credits")}
</Text>
</Group>
}
>
<Stack gap="md">
<Alert color="orange" icon={<WarningIcon />}>
<Text size="sm">
{requiredCredits
? t(
"credits.insufficient.messageWithAmount",
"You need {{required}} credits to run {{tool}}, but you only have {{current}}.",
{
required: requiredCredits,
tool: toolName,
current: creditBalance,
},
)
: t(
"credits.insufficient.message",
"You do not have enough credits to run {{tool}}. You currently have {{current}} credits.",
{
tool: toolName,
current: creditBalance,
},
)}
</Text>
</Alert>
{isManagedTeamMember ? (
<>
<Text size="sm" c="dimmed">
{t(
"credits.insufficient.managedMember",
"Please contact your team leader for assistance.",
)}
</Text>
<Button onClick={onClose} fullWidth>
{t("common.close", "Close")}
</Button>
</>
) : tier === "team" ? (
<>
<Text size="sm" c="dimmed">
{t(
"credits.insufficient.teamMember",
"Enable overage billing to never run out of credits.",
)}
</Text>
{meteringError && <Alert color="red">{meteringError}</Alert>}
<Button
variant="filled"
color="blue"
fullWidth
onClick={handleEnableMetering}
loading={enablingMetering}
disabled={!isTeamLeader}
>
{t("credits.enableOverageBilling", "Enable Overage Billing")}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t(
"credits.modal.teamLeaderOnly",
"Only team leaders can enable overage billing",
)}
</Text>
)}
<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.",
)}
</Text>
<Button
variant="filled"
color="blue"
fullWidth
onClick={() => {
openCheckout("pro");
onClose();
}}
>
{t("credits.upgrade", "Upgrade to Team")}
</Button>
<Button onClick={onClose} variant="subtle" fullWidth>
{t("common.cancel", "Cancel")}
</Button>
</>
)}
</Stack>
</Modal>
);
}
@@ -0,0 +1,62 @@
import { memo, useState, useEffect } from "react";
import CoreToolButton from "@core/components/tools/toolPicker/ToolButton";
import { getToolDisabledReason } from "@app/components/tools/fullscreen/shared";
import {
useToolWorkflowActions,
useToolWorkflowData,
} from "@app/contexts/ToolWorkflowContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
connectionModeService,
type ConnectionMode,
} from "@app/services/connectionModeService";
type CoreToolButtonProps = React.ComponentProps<typeof CoreToolButton>;
/**
* Desktop override of ToolButton.
* In local mode, unavailable tools (except comingSoon/selfHostedOffline) navigate directly
* to the tool UI — the execute button there shows the disabled state with a "click to sign in"
* tooltip, keeping the tool's settings visible and letting the user explore before committing.
* In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge).
*/
const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
const { toolAvailability } = useToolWorkflowData();
const { handleToolSelectForced } = useToolWorkflowActions();
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
null,
);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges((cfg) =>
setConnectionMode(cfg.mode),
);
}, []);
const disabledReason = getToolDisabledReason(
props.id,
props.tool,
toolAvailability,
premiumEnabled,
);
// In local mode, pass a handler so CoreToolButton renders the tool as "cloud-available"
// (full opacity, cloud badge, clickable). Clicking navigates to the tool normally so the
// user can see the settings; the disabled execute button handles the sign-in prompt.
// comingSoon and selfHostedOffline tools remain dimmed — they have no usable UI to show.
const handleUnavailableClick =
connectionMode === "local" &&
disabledReason !== "comingSoon" &&
disabledReason !== "selfHostedOffline"
? () => handleToolSelectForced(props.id)
: undefined;
return (
<CoreToolButton {...props} onUnavailableClick={handleUnavailableClick} />
);
};
export default memo(ToolButton);
@@ -0,0 +1,63 @@
import { useState, useEffect } from "react";
import { Group, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
connectionModeService,
type ConnectionMode,
} from "@app/services/connectionModeService";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
/**
* Desktop-only footer shown at the bottom of the tool list.
* In local (offline) mode: prompts the user to sign in to unlock cloud tools.
* In other modes: renders nothing.
*/
export function ToolPickerFooterExtensions() {
const { t } = useTranslation();
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
null,
);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
const unsubscribe = connectionModeService.subscribeToModeChanges(
(config) => {
setConnectionMode(config.mode);
},
);
return unsubscribe;
}, []);
if (connectionMode !== "local") return null;
return (
<Group
gap="xs"
align="center"
justify="space-between"
wrap="nowrap"
px="sm"
py={10}
style={{
borderTop: "1px solid var(--border-default)",
background: "var(--bg-toolbar)",
flexShrink: 0,
}}
>
<Text size="xs" c="dimmed" style={{ flex: 1, minWidth: 0 }}>
{t("localMode.toolPicker.message", "Sign in to unlock all tools.")}
</Text>
<Button
size="compact-xs"
variant="light"
color="blue"
style={{ flexShrink: 0 }}
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))
}
>
{t("localMode.toolPicker.signIn", "Sign In")}
</Button>
</Group>
);
}
@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { usePrintCapability } from "@embedpdf/plugin-print/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady";
import { printPdfNatively } from "@app/services/nativePrintService";
import { DesktopOs, getDesktopOs } from "@app/services/platformService";
import { PrintAPIBridgeProps } from "@core/components/viewer/PrintAPIBridge";
export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
const { provides: print } = usePrintCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
useEffect(() => {
if (documentReady) {
registerBridge("print", {
state: {},
api: {
print: () => {
void (async () => {
// macOS desktop uses a native print path because Tauri/WKWebView does not
// reliably support iframe-based PDF printing yet:
// https://github.com/tauri-apps/tauri/issues/13451#issuecomment-4045138142
if ((await getDesktopOs()) === DesktopOs.Mac) {
await printPdfNatively(file, url, fileName);
return;
}
print?.print?.();
})().catch((error) => {
console.error("[Desktop Print] Print failed", error);
});
},
},
});
}
return () => {
registerBridge("print", null);
};
}, [documentReady, file, fileName, print, registerBridge, url]);
return null;
}
@@ -0,0 +1,73 @@
/**
* Billing Configuration for Desktop
* Single source of truth for billing-related constants
*/
export const BILLING_CONFIG = {
// Credits included in Free plan (per month)
FREE_CREDITS_PER_MONTH: 50,
// Credits included in Pro plan (per month)
INCLUDED_CREDITS_PER_MONTH: 500,
// Overage pricing (per credit) - also fetched dynamically from Stripe
OVERAGE_PRICE_PER_CREDIT: 0.05,
// Credit warning threshold (shows low-balance indicator)
LOW_CREDIT_THRESHOLD: 10,
// Threshold at which plan pricing is preloaded (higher than LOW_CREDIT_THRESHOLD
// so prices are ready before the warning UI appears)
PLAN_PRICING_PRELOAD_THRESHOLD: 20,
// Stripe lookup keys
PRO_PLAN_LOOKUP_KEY: "plan:pro",
METER_LOOKUP_KEY: "meter:overage",
// Display formats
CURRENCY_SYMBOLS: {
gbp: "£",
usd: "$",
eur: "€",
cny: "¥",
inr: "₹",
brl: "R$",
idr: "Rp",
jpy: "¥",
} as const,
} as const;
/**
* Get current billing configuration
*/
export function getBillingConfig() {
return BILLING_CONFIG;
}
/**
* Format overage price with currency symbol
* @param currency Currency code (e.g., 'usd', 'gbp')
* @param price Optional price override (defaults to BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT)
*/
export function getFormattedOveragePrice(
currency: string = "usd",
price?: number,
): string {
const symbol =
BILLING_CONFIG.CURRENCY_SYMBOLS[
currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS
] || "$";
const amount = price ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
return `${symbol}${amount.toFixed(2)}`;
}
/**
* Get currency symbol from currency code
*/
export function getCurrencySymbol(currency: string): string {
return (
BILLING_CONFIG.CURRENCY_SYMBOLS[
currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS
] || currency.toUpperCase()
);
}
@@ -0,0 +1,10 @@
import { AppConfig } from "@app/contexts/AppConfigContext";
/**
* Default configuration used while the bundled backend starts up.
*/
export const DESKTOP_DEFAULT_APP_CONFIG: AppConfig = {
enableLogin: false,
premiumEnabled: false,
runningProOrHigher: false,
};
@@ -0,0 +1,86 @@
/**
* Desktop plan features configuration
* Single source of truth for plan features in desktop billing page
*/
export interface PlanFeatureConfig {
translationKey: string;
defaultText: string;
}
export const FREE_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: "credits.modal.allInOneWorkspace",
defaultText: "All-in-one PDF workspace (viewer, tools & agent)",
},
{
translationKey: "credits.modal.fullyPrivateFiles",
defaultText: "Fully private files",
},
{
translationKey: "credits.modal.standardThroughput",
defaultText: "Standard throughput",
},
{
translationKey: "credits.modal.customSmartFolders",
defaultText: "Custom Smart Folders",
},
{
translationKey: "credits.modal.apiSandbox",
defaultText: "API sandbox",
},
];
export const TEAM_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: "credits.modal.unlimitedSeats",
defaultText: "Unlimited seats",
},
{
translationKey: "credits.modal.fasterThroughput",
defaultText: "10x faster throughput",
},
{
translationKey: "credits.modal.largeFileProcessing",
defaultText: "Large file processing",
},
{
translationKey: "credits.modal.premiumAiModels",
defaultText: "Premium AI models",
},
{
translationKey: "credits.modal.secureApiAccess",
defaultText: "Secure API access",
},
{
translationKey: "credits.modal.prioritySupport",
defaultText: "Priority support",
},
];
export const ENTERPRISE_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: "credits.modal.orgWideAccess",
defaultText: "Org-wide access controls",
},
{
translationKey: "credits.modal.privateDocCloud",
defaultText: "Private Document Cloud",
},
{
translationKey: "credits.modal.ragFineTuning",
defaultText: "RAG + fine-tuning",
},
{
translationKey: "credits.modal.unlimitedApiAccess",
defaultText: "Unlimited API access",
},
{
translationKey: "credits.modal.advancedMonitoring",
defaultText: "Advanced monitoring",
},
{
translationKey: "credits.modal.dedicatedSupportSlas",
defaultText: "Dedicated support & SLAs",
},
];
@@ -0,0 +1,27 @@
import i18n from "@app/i18n";
export const BACKEND_NOT_READY_CODE = "BACKEND_NOT_READY" as const;
export interface BackendNotReadyError extends Error {
code: typeof BACKEND_NOT_READY_CODE;
}
export function createBackendNotReadyError(): BackendNotReadyError {
return Object.assign(
new Error(i18n.t("backendHealth.starting", "Backend starting up...")),
{
code: BACKEND_NOT_READY_CODE,
},
);
}
export function isBackendNotReadyError(
error: unknown,
): error is BackendNotReadyError {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === BACKEND_NOT_READY_CODE
);
}
@@ -0,0 +1,17 @@
/**
* Connection-related constants for desktop app
*/
// SaaS authentication server URL
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;
// Supabase publishable key — used for SaaS authentication
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";
@@ -0,0 +1,13 @@
/**
* Credit event constants for desktop credit system
* Used for communication between credit monitoring, UI, and operations
*/
export const CREDIT_EVENTS = {
EXHAUSTED: "credits:exhausted",
INSUFFICIENT: "credits:insufficient",
REFRESH_NEEDED: "credits:refresh-needed",
} as const;
export type CreditEventType =
(typeof CREDIT_EVENTS)[keyof typeof CREDIT_EVENTS];
@@ -0,0 +1,5 @@
/**
* CustomEvent name for opening the desktop sign-in modal (SetupWizard).
* Kept in a leaf module so apiClientSetup and others avoid importing SignInModal (heavy graph).
*/
export const OPEN_SIGN_IN_EVENT = "stirling:open-sign-in";
@@ -0,0 +1,86 @@
import React, {
createContext,
useContext,
useState,
ReactNode,
useCallback,
} from "react";
import { SaaSStripeCheckout } from "@app/components/shared/billing/SaaSStripeCheckout";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
interface SaaSCheckoutContextType {
opened: boolean;
selectedPlan: string | null;
openCheckout: (planId: string) => void;
closeCheckout: () => void;
}
const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(
undefined,
);
export const useSaaSCheckout = () => {
const context = useContext(SaaSCheckoutContext);
if (!context) {
throw new Error("useSaaSCheckout must be used within SaaSCheckoutProvider");
}
return context;
};
interface SaaSCheckoutProviderProps {
children: ReactNode;
}
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
children,
}) => {
const [opened, setOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
// Access billing context for auto-refresh after checkout
const { refreshBilling } = useSaaSBilling();
const openCheckout = (planId: string) => {
setSelectedPlan(planId);
setOpened(true);
};
const closeCheckout = () => {
setOpened(false);
// Don't reset selectedPlan immediately to allow for cleanup
setTimeout(() => setSelectedPlan(null), 300);
};
// Internal success handler - automatically refreshes billing context
const handleCheckoutSuccess = useCallback(async () => {
// Wait for webhooks to process (2 seconds)
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
await refreshBilling();
} catch (error) {
console.error(
"[SaaSCheckoutContext] Failed to refresh billing after checkout:",
error,
);
}
}, [refreshBilling]);
return (
<SaaSCheckoutContext.Provider
value={{
opened,
selectedPlan,
openCheckout,
closeCheckout,
}}
>
{children}
<SaaSStripeCheckout
opened={opened}
onClose={closeCheckout}
planId={selectedPlan}
onSuccess={handleCheckoutSuccess}
/>
</SaaSCheckoutContext.Provider>
);
};
@@ -0,0 +1,388 @@
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useCallback,
} from "react";
import apiClient from "@app/services/apiClient";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
/**
* Desktop implementation of SaaS Team Context
* Provides team management for users connected to SaaS backend
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
*/
interface Team {
teamId: number;
name: string;
teamType: string;
isPersonal: boolean;
memberCount: number;
seatCount: number;
seatsUsed: number;
maxSeats: number;
isLeader: boolean;
}
interface TeamMember {
id: number;
username: string;
email: string;
role: string;
joinedAt: string;
}
interface TeamInvitation {
invitationId: number;
teamName: string;
inviterEmail: string;
inviteeEmail: string;
invitationToken: string;
status: string;
expiresAt: string;
}
interface SaaSTeamContextType {
currentTeam: Team | null;
teams: Team[];
teamMembers: TeamMember[];
teamInvitations: TeamInvitation[];
receivedInvitations: TeamInvitation[];
isTeamLeader: boolean;
isPersonalTeam: boolean;
loading: boolean;
inviteUser: (email: string) => Promise<void>;
acceptInvitation: (token: string) => Promise<void>;
rejectInvitation: (token: string) => Promise<void>;
cancelInvitation: (invitationId: number) => Promise<void>;
removeMember: (memberId: number) => Promise<void>;
leaveTeam: () => Promise<void>;
refreshTeams: () => Promise<void>;
}
const SaaSTeamContext = createContext<SaaSTeamContextType>({
currentTeam: null,
teams: [],
teamMembers: [],
teamInvitations: [],
receivedInvitations: [],
isTeamLeader: false,
isPersonalTeam: true,
loading: true,
inviteUser: async () => {},
acceptInvitation: async () => {},
rejectInvitation: async () => {},
cancelInvitation: async () => {},
removeMember: async () => {},
leaveTeam: async () => {},
refreshTeams: async () => {},
});
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const [currentTeam, setCurrentTeam] = useState<Team | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
const [receivedInvitations, setReceivedInvitations] = useState<
TeamInvitation[]
>([]);
const [loading, setLoading] = useState(true);
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Check if in SaaS mode and authenticated
useEffect(() => {
const checkAccess = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === "saas");
setIsAuthenticated(auth);
};
checkAccess();
// Subscribe to connection mode changes
const unsubscribe =
connectionModeService.subscribeToModeChanges(checkAccess);
return unsubscribe;
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
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",
);
return null;
}
try {
const response = await apiClient.get<Team[]>("/api/v1/team/my", {
suppressErrorToast: true,
});
setTeams(response.data);
const activeTeam = response.data[0];
console.log("[SaaSTeamContext] Current team set:", {
teamId: activeTeam?.teamId,
name: activeTeam?.name,
isPersonal: activeTeam?.isPersonal,
isLeader: activeTeam?.isLeader,
});
setCurrentTeam(activeTeam || null);
return activeTeam || null;
} catch (error) {
console.error("[SaaSTeamContext] Failed to fetch teams:", error);
return null;
}
}, [isSaasMode, isAuthenticated]);
const fetchTeamMembers = useCallback(
async (teamId: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
console.log(
"[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated",
);
return;
}
try {
const response = await apiClient.get<TeamMember[]>(
`/api/v1/team/${teamId}/members`,
{ suppressErrorToast: true },
);
setTeamMembers(response.data);
} catch (error) {
console.error("[SaaSTeamContext] Failed to fetch team members:", error);
}
},
[isSaasMode, isAuthenticated],
);
const fetchTeamInvitations = useCallback(
async (teamId?: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated || !teamId) {
return;
}
try {
const response = await apiClient.get<TeamInvitation[]>(
`/api/v1/team/${teamId}/invitations`,
{
suppressErrorToast: true,
},
);
setTeamInvitations(response.data);
} catch (error) {
console.error(
"[SaaSTeamContext] Failed to fetch team invitations:",
error,
);
}
},
[isSaasMode, isAuthenticated],
);
const fetchReceivedInvitations = useCallback(async () => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
return;
}
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,
);
setReceivedInvitations(response.data);
} catch (error) {
console.error(
"[SaaSTeamContext] Failed to fetch received invitations:",
error,
);
}
}, [isSaasMode, isAuthenticated]);
useEffect(() => {
if (isSaasMode && isAuthenticated) {
fetchMyTeams();
fetchReceivedInvitations();
} else {
// Clear state when not in SaaS mode or not authenticated
setTeams([]);
setCurrentTeam(null);
setTeamMembers([]);
setTeamInvitations([]);
setReceivedInvitations([]);
setLoading(false);
}
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations]);
useEffect(() => {
if (
currentTeam &&
!currentTeam.isPersonal &&
isSaasMode &&
isAuthenticated
) {
fetchTeamMembers(currentTeam.teamId);
// Only fetch invitations if user is team leader
if (currentTeam.isLeader) {
fetchTeamInvitations(currentTeam.teamId);
} else {
setTeamInvitations([]);
}
} else {
setTeamMembers([]);
setTeamInvitations([]);
}
setLoading(false);
}, [
currentTeam,
isSaasMode,
isAuthenticated,
fetchTeamMembers,
fetchTeamInvitations,
]);
const inviteUser = async (email: string) => {
if (!currentTeam) throw new Error("No current team");
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post("/api/v1/team/invite", {
teamId: currentTeam.teamId,
email,
});
await fetchTeamInvitations(currentTeam.teamId);
};
const acceptInvitation = async (token: string) => {
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
await fetchReceivedInvitations();
await refreshTeams();
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
};
const rejectInvitation = async (token: string) => {
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
await fetchReceivedInvitations();
};
const cancelInvitation = async (invitationId: number) => {
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
if (currentTeam) {
await fetchTeamInvitations(currentTeam.teamId);
}
};
const removeMember = async (memberId: number) => {
if (!currentTeam) throw new Error("No current team");
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.delete(
`/api/v1/team/${currentTeam.teamId}/members/${memberId}`,
);
await refreshTeams();
await fetchTeamMembers(currentTeam.teamId);
};
const leaveTeam = async () => {
if (!currentTeam) throw new Error("No current team");
if (!isSaasMode) throw new Error("Not in SaaS mode");
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
await refreshTeams();
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
};
const refreshTeams = useCallback(async () => {
if (!isSaasMode || !isAuthenticated) {
console.log(
"[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated",
);
return;
}
const newCurrentTeam = await fetchMyTeams();
await fetchReceivedInvitations();
if (newCurrentTeam && !newCurrentTeam.isPersonal) {
await fetchTeamMembers(newCurrentTeam.teamId);
// Only fetch invitations if user is team leader
if (newCurrentTeam.isLeader) {
await fetchTeamInvitations(newCurrentTeam.teamId);
}
}
}, [
isSaasMode,
isAuthenticated,
fetchMyTeams,
fetchReceivedInvitations,
fetchTeamMembers,
fetchTeamInvitations,
]);
const isTeamLeader = currentTeam?.isLeader ?? false;
const isPersonalTeam = currentTeam?.isPersonal ?? true;
return (
<SaaSTeamContext.Provider
value={{
currentTeam,
teams,
teamMembers,
teamInvitations,
receivedInvitations,
isTeamLeader,
isPersonalTeam,
loading,
inviteUser,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
}}
>
{children}
</SaaSTeamContext.Provider>
);
}
export function useSaaSTeam() {
const context = useContext(SaaSTeamContext);
if (context === undefined) {
throw new Error("useSaaSTeam must be used within a SaaSTeamProvider");
}
return context;
}
export { SaaSTeamContext };
export type { Team, TeamMember, TeamInvitation };
@@ -0,0 +1,440 @@
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useCallback,
useRef,
useMemo,
} from "react";
import {
saasBillingService,
BillingStatus,
PlanPrice,
} from "@app/services/saasBillingService";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import type { TierLevel } from "@app/types/billing";
// How long plan pricing is considered fresh. Lives at module level so both the
// provider and usePlanPricing (the consumer hook) can reference it.
const PLANS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
/**
* Desktop implementation of SaaS Billing Context
* Provides billing and plan management for users connected to SaaS backend
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
*
* Features:
* - Centralized billing state management
* - Automatic caching (5-minute TTL)
* - Lazy loading (fetches on first access)
* - Auto-refresh after checkout/portal
* - No flickering (preserves data during refresh)
*/
interface SaasBillingContextType {
// Billing Status
billingStatus: BillingStatus | null;
tier: TierLevel;
subscription: BillingStatus["subscription"];
usage: BillingStatus["meterUsage"];
isTrialing: boolean;
trialDaysRemaining?: number;
price?: number;
currency?: string;
creditBalance: number; // Real-time remaining credits
// Available Plans
plans: Map<string, PlanPrice>;
plansLoading: boolean;
plansError: string | null;
plansLastFetchTime: number | null;
// Derived State
isManagedTeamMember: boolean;
// State Flags
loading: boolean;
error: string | null;
lastFetchTime: number | null;
// Actions
refreshBilling: () => Promise<void>;
refreshCredits: () => Promise<void>; // Alias for refreshBilling (for clarity)
refreshPlans: () => Promise<void>;
openBillingPortal: () => Promise<void>;
}
const SaasBillingContext = createContext<SaasBillingContextType>({
billingStatus: null,
tier: "free",
subscription: null,
usage: null,
isTrialing: false,
trialDaysRemaining: undefined,
price: undefined,
currency: undefined,
creditBalance: 0,
plans: new Map(),
plansLoading: false,
plansError: null,
plansLastFetchTime: null,
isManagedTeamMember: false,
loading: false,
error: null,
lastFetchTime: null,
refreshBilling: async () => {},
refreshCredits: async () => {},
refreshPlans: async () => {},
openBillingPortal: async () => {},
});
export function SaasBillingProvider({ children }: { children: ReactNode }) {
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);
const [loading, setLoading] = useState(false); // Start false (lazy load)
const [error, setError] = useState<string | null>(null);
// lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure).
// lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context.
const lastFetchTimeRef = useRef<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);
// 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();
// Compute derived state: user is managed member if in non-personal team but not leader
const isManagedTeamMember = currentTeam
? !isPersonalTeam && !isTeamLeader
: false;
// Check if in SaaS mode and authenticated (same pattern as SaaSTeamContext)
useEffect(() => {
const checkAccess = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === "saas");
setIsAuthenticated(auth);
};
checkAccess();
// Subscribe to connection mode changes
const unsubscribe =
connectionModeService.subscribeToModeChanges(checkAccess);
return unsubscribe;
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
// Fetch billing status with caching
const fetchBillingData = useCallback(async () => {
// Guard: Skip if not in SaaS mode or not authenticated
if (!isSaasMode || !isAuthenticated) {
return;
}
// Guard: Wait for team context to load before determining managed status
if (teamLoading) {
return;
}
// Guard: Skip if managed team member (billing managed by leader)
if (isManagedTeamMember) {
return;
}
// Cache check: Skip if fresh data exists (<5 min old)
const now = Date.now();
if (
billingStatusRef.current &&
lastFetchTimeRef.current &&
now - lastFetchTimeRef.current < 300000
) {
return;
}
// Only set loading if no existing data (prevents flicker on refresh)
if (!billingStatusRef.current) {
setLoading(true);
}
try {
const status = await saasBillingService.getBillingStatus();
billingStatusRef.current = status;
setBillingStatus(status);
lastFetchTimeRef.current = now;
setLastFetchTimeValue(now);
setError(null);
} catch (err) {
console.error("[SaasBillingContext] Failed to fetch billing:", err);
setError(err instanceof Error ? err.message : "Failed to fetch billing");
// Don't clear billing status on error (preserve existing data)
} finally {
setLoading(false);
}
}, [isSaasMode, isAuthenticated, isManagedTeamMember, teamLoading]);
// Raw plan fetch — auth guard + in-flight deduplication only.
// TTL cache logic lives in usePlanPricing so the consumer hook controls staleness policy.
const fetchPlansData = useCallback(async () => {
if (!isSaasMode || !isAuthenticated) {
return;
}
if (plansFetchInProgressRef.current) {
return;
}
plansFetchInProgressRef.current = true;
setPlansLoading(true);
setPlansError(null);
try {
const priceMap = await saasBillingService.getAvailablePlans("usd");
setPlans(priceMap);
const now = Date.now();
plansLastFetchTimeRef.current = now;
setPlansLastFetchTimeValue(now);
} catch (err) {
console.error("[SaasBillingContext] Failed to fetch plans:", err);
setPlansError(
err instanceof Error ? err.message : "Failed to fetch plans",
);
} finally {
plansFetchInProgressRef.current = false;
setPlansLoading(false);
}
}, [isSaasMode, isAuthenticated]);
// Clear data when leaving SaaS mode or logging out
useEffect(() => {
if (!isSaasMode || !isAuthenticated) {
// Clear state when not in SaaS mode or not authenticated
billingStatusRef.current = null;
setBillingStatus(null);
setPlans(new Map());
lastFetchTimeRef.current = null;
setLastFetchTimeValue(null);
plansLastFetchTimeRef.current = null;
setPlansLastFetchTimeValue(null);
plansFetchInProgressRef.current = false;
setLoading(false);
setError(null);
setPlansError(null);
}
}, [isSaasMode, isAuthenticated]);
// Auto-fetch billing when team context finishes loading
useEffect(() => {
// Only fetch if: in SaaS mode, authenticated, team finished loading, and haven't fetched yet
if (
isSaasMode &&
isAuthenticated &&
!teamLoading &&
!isManagedTeamMember &&
billingStatusRef.current === null &&
lastFetchTimeRef.current === null
) {
fetchBillingData();
}
}, [
isSaasMode,
isAuthenticated,
teamLoading,
isManagedTeamMember,
fetchBillingData,
]);
// Public refresh methods
const refreshBilling = useCallback(async () => {
if (!isSaasMode || !isAuthenticated) {
return;
}
// Force cache invalidation — write to ref synchronously so fetchBillingData
// reads null immediately (not a stale closure value from the previous render).
lastFetchTimeRef.current = null;
setLastFetchTimeValue(null);
await fetchBillingData();
}, [isSaasMode, isAuthenticated, fetchBillingData]);
const refreshPlans = useCallback(async () => {
await fetchPlansData();
}, [fetchPlansData]);
const openBillingPortal = useCallback(async () => {
const returnUrl = window.location.href;
await saasBillingService.openBillingPortal(returnUrl);
// Auto-refresh after portal (delayed for webhook processing)
setTimeout(() => {
refreshBilling();
}, 3000);
}, [refreshBilling]);
// Listen for credit updates from API response headers (immediate, no fetch)
useEffect(() => {
const handleCreditsUpdated = (e: Event) => {
const customEvent = e as CustomEvent<{ creditsRemaining: number }>;
const newBalance = customEvent.detail?.creditsRemaining;
if (typeof newBalance === "number" && billingStatus) {
// Update credit balance in billing status without full refresh
const updated = { ...billingStatus, creditBalance: newBalance };
billingStatusRef.current = updated;
setBillingStatus(updated);
// Dispatch exhausted event if credits hit 0
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
window.dispatchEvent(
new CustomEvent("credits:exhausted", {
detail: {
previousBalance: billingStatus.creditBalance ?? 0,
currentBalance: newBalance,
},
}),
);
}
}
};
window.addEventListener("credits:updated", handleCreditsUpdated);
return () => {
window.removeEventListener("credits:updated", handleCreditsUpdated);
};
}, [billingStatus]);
const contextValue = useMemo(
() => ({
billingStatus,
tier: isManagedTeamMember ? "team" : (billingStatus?.tier ?? "free"),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: plans.get("team")?.price,
currency: plans.get("team")?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
plansLastFetchTime: plansLastFetchTimeValue,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime: lastFetchTimeValue,
refreshBilling,
refreshCredits: refreshBilling,
refreshPlans,
openBillingPortal,
}),
[
billingStatus,
isManagedTeamMember,
plans,
plansLoading,
plansError,
plansLastFetchTimeValue,
loading,
teamLoading,
error,
lastFetchTimeValue,
refreshBilling,
refreshPlans,
openBillingPortal,
],
);
return (
<SaasBillingContext.Provider value={contextValue}>
{children}
</SaasBillingContext.Provider>
);
}
export function useSaaSBilling() {
const context = useContext(SaasBillingContext);
if (context === undefined) {
throw new Error("useSaaSBilling must be used within SaasBillingProvider");
}
// Lazy fetch: Trigger fetch on first access (after team context loads)
// Note: context.loading includes teamLoading, so this waits for team to load
useEffect(() => {
const needsFetch =
context.billingStatus === null &&
context.lastFetchTime === null &&
!context.loading &&
!context.isManagedTeamMember; // Managed members don't need billing data
if (needsFetch) {
context.refreshBilling();
}
}, [
context.billingStatus,
context.lastFetchTime,
context.loading,
context.isManagedTeamMember,
context.refreshBilling,
]);
return context;
}
/**
* Hook for components that display plan pricing data.
* Fetches on first use; re-fetches only after PLANS_CACHE_TTL_MS (1 hour).
* Safe to call from multiple components — in-flight deduplication is handled by fetchPlansData.
*/
export function usePlanPricing() {
const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } =
useContext(SaasBillingContext);
useEffect(() => {
const isFresh =
plansLastFetchTime !== null &&
Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
if (!isFresh) {
refreshPlans();
}
}, [plansLastFetchTime, refreshPlans]);
return { plans, plansLoading, plansError };
}
export { SaasBillingContext };
export type { SaasBillingContextType };
@@ -0,0 +1,45 @@
import { connectionModeService } from "@app/services/connectionModeService";
type SignOutFn = () => Promise<void>;
interface AccountLogoutDeps {
signOut: SignOutFn;
redirectToLogin: () => void;
}
/**
* Desktop-specific logout: mirrors Connection Settings flow to avoid stale state.
*/
export function useAccountLogout() {
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
) {
localStorage.setItem("server_url", currentConfig.server_config.url);
}
// Always switch to local after logout so the app remains usable
await connectionModeService.switchToLocal();
window.history.replaceState({}, "", "/");
// No reload needed — AppProviders remounts the SaaS provider tree via
// connectionModeService subscription when mode changes to local.
return;
} catch (err) {
console.warn(
"[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect",
err,
);
}
redirectToLogin();
};
}
@@ -0,0 +1,31 @@
/**
* Desktop-specific OAuth callback handling for self-hosted SSO flows.
*/
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";
if (isDesktopPopup) {
try {
window.opener.postMessage({ type: "stirling-desktop-sso", token }, "*");
} catch (postError) {
console.error(
"[AuthCallback] Failed to notify desktop window:",
postError,
);
}
// Give the message a moment to flush before attempting to close
setTimeout(() => {
try {
window.close();
} catch {
// ignore close errors
}
}, 150);
}
// No-op beyond popup notification; deep link flow handles desktop completion.
}
@@ -0,0 +1,50 @@
import { authService } from "@app/services/authService";
/**
* Desktop-specific auth cleanup hooks.
*/
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,
);
}
}
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,
);
if (!token) {
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)",
);
}
} catch (err) {
console.warn(
"[AuthCleanup] Failed to clear desktop auth data on login init",
err,
);
}
}
@@ -0,0 +1,7 @@
export function getCookieConsentOverrides(): Record<string, unknown> {
return {
cookie: {
useLocalStorage: true, // Cookies don't reliably persist on desktop, but localStorage does
},
};
}
@@ -0,0 +1,27 @@
import { authService } from "@app/services/authService";
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> {
try {
const currentConfig = await connectionModeService
.getCurrentConfig()
.catch(() => null);
const serverUrl = currentConfig?.server_config?.url;
if (!serverUrl) {
return false;
}
const providerUrl = new URL(redirectUrl, serverUrl);
const providerPath = `${providerUrl.pathname}${providerUrl.search}`;
await authService.loginWithSelfHostedOAuth(providerPath, serverUrl);
return true;
} catch (error) {
console.warn("[Desktop OAuthNavigation] Failed to start OAuth flow", error);
return false;
}
}
@@ -0,0 +1,62 @@
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
import type { PlatformSessionUser } from "@proprietary/extensions/platformSessionBridge";
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
// Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
// This skips redundant backend validation in springAuthClient since desktop authService
// already manages the token lifecycle
return mode === "saas" || mode === "selfhosted";
} catch {
return false;
}
}
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
try {
const userInfo = await authService.getUserInfo();
if (!userInfo) {
return null;
}
return {
username: userInfo.username,
email: userInfo.email,
};
} catch {
return null;
}
}
export async function refreshPlatformSession(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
if (mode === "saas") {
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
} else if (mode === "selfhosted") {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
return false;
}
return await authService.refreshToken(serverConfig.url);
}
return false;
} catch {
return false;
}
}
/**
* Save token to platform-specific secure storage (Tauri store + localStorage)
* Called after token refresh to ensure token is synced across all storage locations
*/
export async function savePlatformToken(token: string): Promise<void> {
try {
await authService.saveToken(token);
} catch (error) {
console.error("[PlatformBridge] Failed to save token:", error);
throw error;
}
}
@@ -0,0 +1,115 @@
import { useEffect, useState } from "react";
import { useOpenedFile } from "@app/hooks/useOpenedFile";
import { fileOpenService } from "@app/services/fileOpenService";
import { useFileManagement } from "@app/contexts/file/fileHooks";
import { createQuickKey } from "@app/types/fileContext";
/**
* App initialization hook
* Desktop version: Handles Tauri-specific file initialization
* Requires FileContext - must be used inside FileContextProvider
* - Handles files opened with the app (adds directly to FileContext)
*/
export function useAppInitialization(): void {
// Get file management actions
const { addFiles, updateStirlingFileStub } = useFileManagement();
// Handle files opened with app (Tauri mode)
const {
openedFilePaths,
loading: openedFileLoading,
consumeOpenedFilePaths,
} = useOpenedFile();
// Load opened files and add directly to FileContext
useEffect(() => {
if (openedFilePaths.length === 0 || openedFileLoading) {
return;
}
const loadOpenedFiles = async () => {
const filePaths = consumeOpenedFilePaths();
if (filePaths.length === 0) {
return;
}
try {
const loadedFiles = (
await Promise.all(
filePaths.map(async (filePath) => {
try {
const fileData =
await fileOpenService.readFileAsArrayBuffer(filePath);
if (!fileData) return null;
const file = new File(
[fileData.arrayBuffer],
fileData.fileName,
{
type: "application/pdf",
},
);
console.log("[Desktop] Loaded file:", fileData.fileName);
return {
file,
filePath,
quickKey: createQuickKey(file),
};
} catch (error) {
console.error(
"[Desktop] Failed to load file:",
filePath,
error,
);
return null;
}
}),
)
).filter(
(
entry,
): entry is { file: File; filePath: string; quickKey: string } =>
Boolean(entry),
);
if (loadedFiles.length > 0) {
const filesArray = loadedFiles.map((entry) => entry.file);
const quickKeyToPath = new Map(
loadedFiles.map((entry) => [entry.quickKey, entry.filePath]),
);
const addedFiles = await addFiles(filesArray, { selectFiles: true });
addedFiles.forEach((file) => {
const localFilePath = quickKeyToPath.get(file.quickKey);
if (localFilePath) {
updateStirlingFileStub(file.fileId, { localFilePath });
}
});
console.log(
`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`,
);
}
} catch (error) {
console.error("[Desktop] Failed to load opened files:", error);
}
};
loadOpenedFiles();
}, [
openedFilePaths,
openedFileLoading,
addFiles,
updateStirlingFileStub,
consumeOpenedFilePaths,
]);
}
export function useSetupCompletion(): (completed: boolean) => void {
const [, setSetupComplete] = useState(false);
return (completed: boolean) => {
setSetupComplete(completed);
};
}
@@ -0,0 +1,70 @@
import { useEffect, useState, useCallback } from "react";
import { backendHealthMonitor } from "@app/services/backendHealthMonitor";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { connectionModeService } from "@app/services/connectionModeService";
import type { BackendHealthState } from "@app/types/backendHealth";
/**
* Hook to read backend health state for UI (Run button, BackendHealthIndicator).
*
* backendHealthMonitor tracks the local bundled backend.
* selfHostedServerMonitor tracks the remote server in self-hosted mode.
*
* isOnline logic:
* - SaaS mode: true when local backend is healthy
* - Self-hosted mode (server online): true (remote is up)
* - Self-hosted mode (server offline, local port known): true so the Run button
* stays enabled — operationRouter routes supported tools to local
* - 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 [connectionMode, setConnectionMode] = useState<string | null>(null);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges((config) =>
setConnectionMode(config.mode),
);
}, []);
useEffect(() => {
return backendHealthMonitor.subscribe(setHealth);
}, []);
useEffect(() => {
return selfHostedServerMonitor.subscribe((state) =>
setServerStatus(state.status),
);
}, []);
useEffect(() => {
return tauriBackendService.subscribeToStatus(() => {
setLocalUrl(tauriBackendService.getBackendUrl());
});
}, []);
const checkHealth = useCallback(async () => {
return backendHealthMonitor.checkNow();
}, []);
const isOnline =
connectionMode === "selfhosted"
? serverStatus !== "offline" || !!localUrl
: health.isOnline;
return {
...health,
isOnline,
checkHealth,
};
}
@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
import { useEndpointConfig } from "@app/hooks/useEndpointConfig";
import { tauriBackendService } from "@app/services/tauriBackendService";
/**
* Hook to initialize backend and monitor health
* @param enabled - Whether to initialize the backend (default: true)
*/
export function useBackendInitializer(enabled = true) {
const { status, checkHealth } = useBackendHealth();
const { backendUrl } = useEndpointConfig();
useEffect(() => {
// Skip if disabled
if (!enabled) {
return;
}
// Skip if backend already running
if (tauriBackendService.isBackendRunning()) {
void checkHealth();
return;
}
const initializeBackend = async () => {
try {
await tauriBackendService.startBackend(backendUrl);
// Begin health checks after a short delay
setTimeout(() => {
void checkHealth();
}, 500);
} catch (error) {
console.error("[BackendInitializer] Failed to start backend:", error);
}
};
// Only start backend if it's not already starting/healthy
if (status !== "healthy" && status !== "starting") {
void initializeBackend();
}
}, [enabled, status, backendUrl, checkHealth]);
}
@@ -0,0 +1,185 @@
import { useState, useEffect } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { EXTENSION_TO_ENDPOINT } from "@app/constants/convertConstants";
import { getEndpointName } from "@app/utils/convertUtils";
/**
* Comprehensive conversion status data
*/
export interface ConversionStatus {
availability: Record<string, boolean>; // Available on local OR SaaS?
cloudStatus: Record<string, boolean>; // Will use cloud?
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
}
/**
* Desktop hook to check conversion availability and cloud routing
* Returns comprehensive data about each conversion
* @returns Object with availability, cloudStatus, and localOnly maps
*/
export function useConversionCloudStatus(): ConversionStatus {
const [status, setStatus] = useState<ConversionStatus>({
availability: {},
cloudStatus: {},
localOnly: {},
});
useEffect(() => {
const checkConversions = async () => {
const mode = await connectionModeService.getCurrentMode();
// Self-hosted offline path: server is down but local backend is available.
// Check each conversion against the local backend only (no cloud routing).
if (mode === "selfhosted") {
const { status } = selfHostedServerMonitor.getSnapshot();
const localUrl = tauriBackendService.getBackendUrl();
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] || {},
)) {
const endpointName = getEndpointName(fromExt, toExt);
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
}
}
const availability: Record<string, boolean> = {};
const cloudStatus: Record<string, boolean> = {};
const localOnly: Record<string, boolean> = {};
const results = await Promise.all(
pairs.map(async ([fromExt, toExt, endpointName]) => {
const key = `${fromExt}-${toExt}`;
try {
const supported =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
localUrl,
);
return { key, supported };
} catch {
return { key, supported: false };
}
}),
);
for (const { key, supported } of results) {
availability[key] = supported;
cloudStatus[key] = false;
localOnly[key] = supported;
}
setStatus({ availability, cloudStatus, localOnly });
return;
}
// Server online or local not ready: let normal endpoint checking handle it
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
// Don't check until backend is healthy (SaaS startup guard)
if (!tauriBackendService.isOnline) {
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
if (mode !== "saas") {
// Non-SaaS, non-self-hosted: local endpoint checking handles everything
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
const availability: Record<string, boolean> = {};
const cloudStatus: Record<string, boolean> = {};
const localOnly: Record<string, boolean> = {};
// Collect all conversion pairs first, then check all in parallel
const pairs: [string, string, string][] = [];
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
for (const toExt of Object.keys(EXTENSION_TO_ENDPOINT[fromExt] || {})) {
const endpointName = getEndpointName(fromExt, toExt);
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
}
}
const results = await Promise.all(
pairs.map(async ([fromExt, toExt, endpointName]) => {
const key = `${fromExt}-${toExt}`;
try {
// 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,
};
} catch (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,
};
}
}),
);
for (const {
key,
isAvailable,
willUseCloud: wuc,
localOnly: lo,
} of results) {
availability[key] = isAvailable;
cloudStatus[key] = wuc;
localOnly[key] = lo;
}
setStatus({ availability, cloudStatus, localOnly });
};
// Initial check
checkConversions();
// Re-check when SaaS local backend becomes healthy
const unsubLocal = tauriBackendService.subscribeToStatus((status) => {
if (status === "healthy") {
checkConversions();
}
});
// Re-check when self-hosted server goes offline or comes back online.
// By the time the server is confirmed offline, the local port is already
// discovered (waitForPort completes in ~500ms vs the 8s server poll timeout).
// selfHostedServerMonitor.subscribe immediately invokes the listener with the
// current state, which would cause a duplicate check alongside the one above.
// Skip the first invocation since checkConversions() was already called above.
let skipFirst = true;
const unsubServer = selfHostedServerMonitor.subscribe(() => {
if (skipFirst) {
skipFirst = false;
return;
}
void checkConversions();
});
return () => {
unsubLocal();
unsubServer();
};
}, []);
return status;
}
@@ -0,0 +1,71 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { getToolCreditCost } from "@app/utils/creditCosts";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
import { operationRouter } from "@app/services/operationRouter";
import type { ToolId } from "@app/types/toolId";
/**
* Desktop implementation of credit checking for cloud operations.
* Hooks are called at render time; the returned checkCredits callback
* closes over the billing state so it can be called safely inside
* async operation handlers.
*
* Returns null when the operation is allowed, or an error message string
* when it should be blocked.
*/
export function useCreditCheck(operationType?: string, endpoint?: string) {
const billing = useSaaSBilling();
const isSaaSMode = useSaaSMode();
const { t } = useTranslation();
const checkCredits = useCallback(
async (runtimeEndpoint?: string): Promise<string | null> => {
if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted
if (!billing) return null;
// If the operation routes to the local backend, no credits are consumed — skip check.
// runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint.
const ep = runtimeEndpoint ?? endpoint;
if (ep) {
try {
const willUseSaaS = await operationRouter.willRouteToSaaS(ep);
if (!willUseSaaS) return null;
} catch {
// If routing check fails, fall through to credit check as a safe default.
}
}
const { creditBalance, loading } = billing;
const requiredCredits = getToolCreditCost(operationType as ToolId);
if (!loading && creditBalance < requiredCredits) {
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, {
detail: {
operationType,
requiredCredits,
currentBalance: creditBalance,
},
}),
);
return t(
"credits.insufficient.brief",
"Insufficient credits. You need {{required}} credits but have {{current}}.",
{
required: requiredCredits,
current: creditBalance,
},
);
}
return null;
},
[billing, isSaaSMode, operationType, endpoint, t],
);
return { checkCredits };
}
@@ -0,0 +1,35 @@
import { useEffect, useRef } from "react";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
/**
* Desktop hook that monitors credit balance and dispatches events
* when credits are exhausted or low.
* Only active in SaaS mode — self-hosted users have no credit balance.
*/
export function useCreditEvents() {
const isSaaSMode = useSaaSMode();
const { creditBalance } = useSaaSBilling();
const prevBalanceRef = useRef(creditBalance);
useEffect(() => {
const prevBalance = prevBalanceRef.current;
// Dispatch exhausted event when credits reach 0 from positive balance.
// Skip entirely in self-hosted mode — creditBalance defaults to 0 there.
if (isSaaSMode && creditBalance <= 0 && prevBalance > 0) {
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: {
previousBalance: prevBalance,
currentBalance: creditBalance,
},
}),
);
}
// Update ref for next comparison
prevBalanceRef.current = creditBalance;
}, [isSaaSMode, creditBalance]);
}
@@ -0,0 +1,70 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { defaultAppService } from "@app/services/defaultAppService";
import { alert } from "@app/components/toast";
export const useDefaultApp = () => {
const { t } = useTranslation();
const [isDefault, setIsDefault] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
checkDefaultStatus();
}, []);
const checkDefaultStatus = async () => {
try {
const status = await defaultAppService.isDefaultPdfHandler();
setIsDefault(status);
} catch (error) {
console.error("Failed to check default status:", error);
}
};
const handleSetDefault = async () => {
setIsLoading(true);
try {
const result = await defaultAppService.setAsDefaultPdfHandler();
if (result === "set_successfully") {
alert({
alertType: "success",
title: t("defaultApp.success.title", "Default App Set"),
body: t(
"defaultApp.success.message",
"Stirling PDF is now your default PDF editor",
),
});
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",
),
});
}
} catch (error) {
console.error("Failed to set default:", error);
alert({
alertType: "error",
title: t("defaultApp.error.title", "Error"),
body: t(
"defaultApp.error.message",
"Failed to set default PDF handler",
),
});
} finally {
setIsLoading(false);
}
};
return {
isDefault,
isLoading,
checkDefaultStatus,
handleSetDefault,
};
};
@@ -0,0 +1,69 @@
import { useState } from "react";
import { supabase } from "@app/auth/supabase";
import { authService } from "@app/services/authService";
/**
* Shared hook for enabling metered (overage) billing via Supabase edge function.
* Used by CreditExhaustedModal and InsufficientCreditsModal to avoid duplicate logic.
*
* @param refreshBilling - Callback to refresh billing state after success
* @param onSuccess - Callback invoked after billing is enabled and refreshed
* @param logPrefix - Label used in console messages for easier tracing
*/
export function useEnableMeteredBilling(
refreshBilling: () => Promise<void>,
onSuccess: () => void,
logPrefix: string,
): {
enablingMetering: boolean;
meteringError: string | null;
handleEnableMetering: () => Promise<void>;
} {
const [enablingMetering, setEnablingMetering] = useState(false);
const [meteringError, setMeteringError] = useState<string | null>(null);
const handleEnableMetering = async () => {
console.debug(`[${logPrefix}] Enabling metered billing`);
setEnablingMetering(true);
setMeteringError(null);
try {
const token = await authService.getAuthToken();
if (!token) {
throw new Error("Not authenticated");
}
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",
);
}
console.debug(`[${logPrefix}] Metered billing enabled successfully`);
await refreshBilling();
onSuccess();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to enable metered billing";
console.error(`[${logPrefix}] Failed to enable metered billing:`, err);
setMeteringError(message);
} finally {
setEnablingMetering(false);
}
};
return { enablingMetering, meteringError, handleEnableMetering };
}
@@ -0,0 +1,482 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { isBackendNotReadyError } from "@app/constants/backendErrors";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
import { connectionModeService } from "@app/services/connectionModeService";
import type { AppConfig } from "@app/contexts/AppConfigContext";
interface EndpointConfig {
backendUrl: string;
}
const RETRY_DELAY_MS = 2500;
function isSelfHostedOffline(): boolean {
return (
selfHostedServerMonitor.getSnapshot().status === "offline" &&
!!tauriBackendService.getBackendUrl()
);
}
function getErrorMessage(err: unknown): string {
if (isAxiosError(err)) {
const data = err.response?.data as { message?: string } | undefined;
if (typeof data?.message === "string") {
return data.message;
}
return err.message || "Unknown error occurred";
}
if (err instanceof Error) {
return err.message;
}
return "Unknown error occurred";
}
async function checkDependenciesReady(): Promise<boolean> {
try {
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);
return false;
}
}
/**
* Desktop-specific endpoint checker that hits the backend directly via axios.
*/
export function useEndpointEnabled(endpoint: string): {
enabled: boolean | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const { t } = useTranslation();
// DESKTOP: Start optimistically as enabled (most desktop users are in SaaS mode)
// This prevents UI from being disabled while backend starts or checks are in progress
const [enabled, setEnabled] = useState<boolean | null>(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
useEffect(() => {
return () => {
isMountedRef.current = false;
clearRetryTimeout();
};
}, [clearRetryTimeout]);
const fetchEndpointStatus = useCallback(async () => {
clearRetryTimeout();
if (!endpoint) {
if (!isMountedRef.current) return;
setEnabled(null);
setLoading(false);
return;
}
const dependenciesReady = await checkDependenciesReady();
if (!dependenciesReady) {
return; // Health monitor will trigger retry when truly ready
}
try {
setError(null);
const response = await apiClient.get<boolean>(
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
{
suppressErrorToast: true,
},
);
const locallyEnabled = response.data;
if (!locallyEnabled) {
const mode = await connectionModeService.getCurrentMode();
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
// Even if not supported locally, they will route to SaaS backend
if (mode === "saas") {
console.debug(
`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
);
setEnabled(true);
return;
}
}
setEnabled(locallyEnabled);
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
if (isBackendStarting) {
setError(t("backendHealth.starting", "Backend starting up..."));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
fetchEndpointStatus();
}, RETRY_DELAY_MS);
}
} else {
// 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`,
);
setEnabled(true); // Available via SaaS
setError(null);
return;
}
setError(message);
setEnabled(false);
}
} finally {
setLoading(false);
}
}, [endpoint, clearRetryTimeout, t]);
useEffect(() => {
if (!endpoint) {
setEnabled(null);
setLoading(false);
return;
}
// In self-hosted offline mode, enable optimistically when the local backend is ready.
// ConvertSettings already filters unsupported endpoints from the dropdown,
// so by the time the user has a valid endpoint selected it is supported locally.
if (isSelfHostedOffline()) {
setEnabled(true);
setLoading(false);
// Re-evaluate if the server comes back online
return selfHostedServerMonitor.subscribe(() => {
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
fetchEndpointStatus();
}
});
}
if (tauriBackendService.isOnline) {
fetchEndpointStatus();
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === "healthy") {
fetchEndpointStatus();
}
});
return () => {
unsubscribe();
};
}, [endpoint, fetchEndpointStatus]);
return {
enabled,
loading,
error,
refetch: fetchEndpointStatus,
};
}
export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const { t } = useTranslation();
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);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
useEffect(() => {
return () => {
isMountedRef.current = false;
clearRetryTimeout();
};
}, [clearRetryTimeout]);
const fetchAllEndpointStatuses = useCallback(async () => {
clearRetryTimeout();
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
return;
}
// Self-hosted offline: check each endpoint against the local backend directly.
// checkDependenciesReady() would fail here since it hits the offline remote server.
const { status: serverStatus } = selfHostedServerMonitor.getSnapshot();
const localUrl = tauriBackendService.getBackendUrl();
if (serverStatus === "offline" && localUrl) {
const results = await Promise.all(
[...new Set(endpoints)].map(async (ep) => {
try {
const supported =
await endpointAvailabilityService.isEndpointSupportedLocally(
ep,
localUrl,
);
return { ep, supported };
} catch {
return { ep, supported: false };
}
}),
);
if (!isMountedRef.current) return;
const statusMap: Record<string, boolean> = {};
const details: Record<string, EndpointAvailabilityDetails> = {};
for (const { ep, supported } of results) {
statusMap[ep] = supported;
details[ep] = {
enabled: supported,
reason: supported ? null : "NOT_SUPPORTED_LOCALLY",
};
}
setEndpointDetails((prev) => ({ ...prev, ...details }));
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
setLoading(false);
return;
}
const dependenciesReady = await checkDependenciesReady();
if (!dependenciesReady) {
return; // Health monitor will trigger retry when truly ready
}
try {
setError(null);
// 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>>
>
>;
try {
response = await apiClient.get<
Record<string, EndpointAvailabilityDetails>
>(`/api/v1/config/endpoints-availability`, {
suppressErrorToast: true,
});
} catch (innerErr) {
if (isAxiosError(innerErr) && innerErr.response?.status === 400) {
// Old server — requires explicit endpoints query param
console.debug(
"[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format",
);
const endpointsParam = endpoints.join(",");
response = await apiClient.get<
Record<string, EndpointAvailabilityDetails>
>(
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
{ suppressErrorToast: true },
);
} else {
throw innerErr;
}
}
const details = Object.entries(response.data).reduce(
(acc, [endpointName, detail]) => {
acc[endpointName] = {
enabled: detail?.enabled ?? false,
reason: detail?.reason ?? null,
};
return acc;
},
{} as Record<string, EndpointAvailabilityDetails>,
);
const statusMap = Object.keys(details).reduce(
(acc, key) => {
acc[key] = details[key].enabled;
return acc;
},
{} as Record<string, boolean>,
);
const mode = await connectionModeService.getCurrentMode();
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
// They will route to SaaS backend
if (mode === "saas") {
const disabledEndpoints = Object.keys(details).filter(
(key) => !details[key].enabled,
);
for (const endpoint of disabledEndpoints) {
console.debug(
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
);
statusMap[endpoint] = true; // Mark as enabled via SaaS
details[endpoint] = { enabled: true, reason: null };
}
}
setEndpointDetails((prev) => ({ ...prev, ...details }));
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
if (isBackendStarting) {
setError(t("backendHealth.starting", "Backend starting up..."));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
fetchAllEndpointStatuses();
}, RETRY_DELAY_MS);
}
} else {
setError(message);
const fallbackStatus = endpoints.reduce(
(acc, endpointName) => {
const fallbackDetail: EndpointAvailabilityDetails = {
enabled: false,
reason: "UNKNOWN",
};
acc.status[endpointName] = false;
acc.details[endpointName] = fallbackDetail;
return acc;
},
{
status: {} as Record<string, boolean>,
details: {} as Record<string, EndpointAvailabilityDetails>,
},
);
// 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`,
);
fallbackStatus.status[endpoint] = true;
fallbackStatus.details[endpoint] = { enabled: true, reason: null };
}
}
setEndpointStatus(fallbackStatus.status);
setEndpointDetails((prev) => ({ ...prev, ...fallbackStatus.details }));
}
} finally {
setLoading(false);
}
}, [endpoints, clearRetryTimeout, t]);
useEffect(() => {
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setEndpointDetails({});
setLoading(false);
return;
}
if (isSelfHostedOffline()) {
fetchAllEndpointStatuses();
const unsubServer = selfHostedServerMonitor.subscribe(() => {
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
fetchAllEndpointStatuses();
}
});
return unsubServer;
}
if (tauriBackendService.isOnline) {
fetchAllEndpointStatuses();
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === "healthy") {
fetchAllEndpointStatuses();
}
});
return () => {
unsubscribe();
};
}, [endpoints, fetchAllEndpointStatuses]);
return {
endpointStatus,
endpointDetails,
loading,
error,
refetch: fetchAllEndpointStatuses,
};
}
// Default backend URL from environment variables
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.
* - SaaS mode: Uses local bundled backend (from env vars)
* - Self-hosted mode: Uses configured server URL from connection config
*/
export function useEndpointConfig(): EndpointConfig {
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
useEffect(() => {
connectionModeService
.getCurrentConfig()
.then((config) => {
if (config.mode === "selfhosted" && config.server_config?.url) {
setBackendUrl(config.server_config.url);
} else {
// SaaS mode - use default from env vars (local backend)
setBackendUrl(DEFAULT_BACKEND_URL);
}
})
.catch((err) => {
console.error("Failed to get connection config:", err);
// Keep current URL on error
});
}, []);
return { backendUrl };
}
@@ -0,0 +1,152 @@
import { useEffect, useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { downloadFile } from "@app/services/downloadService";
import type { StirlingFileStub } from "@app/types/fileContext";
import { useTranslation } from "react-i18next";
export function useExitWarning() {
const { t } = useTranslation();
const { selectors } = useFileState();
const { actions: fileActions } = useFileActions();
const selectorsRef = useRef(selectors);
const isClosingRef = useRef(false);
selectorsRef.current = selectors;
useEffect(() => {
const appWindow = getCurrentWindow();
const handleCloseRequested = async (event: {
preventDefault: () => void;
}) => {
event.preventDefault();
if (isClosingRef.current) {
return;
}
const allStubs = selectorsRef.current.getStirlingFileStubs();
const dirtyStubs = allStubs.filter((stub) => stub.isDirty);
if (dirtyStubs.length > 0) {
const fileList = dirtyStubs.map((f) => `${f.name}`).join("\n");
const saveLabel = t("confirmCloseSave", "Save and close");
const discardLabel = t(
"confirmCloseDiscard",
"Discard changes and close",
);
const cancelLabel = t("confirmCloseCancel", "Cancel");
const 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,
},
),
{
title: t("confirmCloseUnsaved", "This file has unsaved changes."),
kind: "warning",
buttons: {
yes: saveLabel,
no: discardLabel,
cancel: cancelLabel,
},
},
);
if (choice === cancelLabel) {
return;
}
if (choice === saveLabel) {
const { failedCount, cancelled } = await saveDirtyFiles(dirtyStubs);
if (cancelled) {
return;
}
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",
},
);
return;
}
} else if (choice !== discardLabel) {
return;
}
}
isClosingRef.current = true;
try {
await appWindow.destroy();
} catch (error) {
console.error("[exit-warning] destroy failed", error);
isClosingRef.current = false;
}
};
const unlisten = appWindow.onCloseRequested(handleCloseRequested);
return () => {
unlisten.then((fn) => {
fn();
});
};
}, [fileActions, t]);
const saveDirtyFiles = async (dirtyStubs: StirlingFileStub[]) => {
const filesById = new Map(
selectorsRef.current.getFiles().map((file) => [file.fileId, file]),
);
let failedCount = 0;
let cancelled = false;
for (const stub of dirtyStubs) {
const file = filesById.get(stub.id);
if (!file || !stub.localFilePath) {
if (!file) {
failedCount += 1;
continue;
}
}
try {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: stub.localFilePath,
});
if (result.cancelled) {
cancelled = true;
break;
}
if (result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false,
});
}
} catch {
failedCount += 1;
}
}
return { failedCount, cancelled };
};
}
@@ -0,0 +1,19 @@
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import SaveOutlinedIcon from "@mui/icons-material/SaveOutlined";
/**
* File action icons for desktop builds.
* Overrides the core implementation with desktop-appropriate icons.
* The presence of `saveAsIconName` signals the WorkbenchBar to show the Save As button.
*/
export function useFileActionIcons() {
return {
upload: FolderOpenOutlinedIcon,
download: SaveOutlinedIcon,
uploadIconName: "folder-rounded" as const,
downloadIconName: "save-rounded" as const,
// Returning this icon name causes WorkbenchBar to render the Save As button.
// On desktop, downloadFile() without a localPath shows a native save dialog.
saveAsIconName: "save-as-rounded" as string | undefined,
};
}
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
/**
* File action terminology for desktop builds
* Overrides core implementation with desktop-appropriate terminology
*/
export function useFileActionTerminology() {
const { t } = useTranslation();
return {
uploadFiles: t("fileUpload.openFiles", "Open Files"),
uploadFile: t("fileUpload.openFile", "Open File"),
upload: t("fileUpload.open", "Open"),
dropFilesHere: t(
"fileUpload.dropFilesHereOpen",
"Drop files here or click the open button",
),
addFiles: t("fileUpload.openFiles", "Open Files"),
mobileUpload: t("landing.mobileUpload", "Upload from Mobile"),
uploadFromComputer: t("landing.openFromComputer", "Open from computer"),
download: t("save", "Save"),
downloadAll: t("workbenchBar.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.",
),
};
}
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService";
/**
* First launch check hook
* 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;
} {
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCheckCompleteRef = useRef(false);
// Check if this is first launch
useEffect(() => {
const checkFirstLaunch = async () => {
try {
const firstLaunch = await connectionModeService.isFirstLaunch();
setIsFirstLaunch(firstLaunch);
if (!firstLaunch) {
// Not first launch - initialize auth state
await authService.initializeAuthState();
setSetupComplete(true);
}
setupCheckCompleteRef.current = true;
} catch (error) {
console.error("Failed to check first launch:", error);
// On error, assume not first launch and proceed
setIsFirstLaunch(false);
setSetupComplete(true);
setupCheckCompleteRef.current = true;
}
};
if (!setupCheckCompleteRef.current) {
checkFirstLaunch();
}
}, []);
return { isFirstLaunch, setupComplete };
}
@@ -0,0 +1,52 @@
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 {
const [appVersion, setAppVersion] = useState<string | null>(null);
const [mismatchVersion, setMismatchVersion] = useState(false);
useEffect(() => {
let cancelled = false;
const fetchVersion = async () => {
try {
const version = await getVersion();
if (!cancelled) {
setAppVersion(version);
}
} catch (error) {
console.error(
"[useFrontendVersionInfo] Failed to fetch frontend version:",
error,
);
}
};
fetchVersion();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!appVersion || !backendVersion) {
setMismatchVersion(false);
return;
}
if (appVersion !== backendVersion) {
console.warn(
"[useFrontendVersionInfo] Mismatch between frontend version and AppConfig version:",
{
backendVersion,
frontendVersion: appVersion,
},
);
setMismatchVersion(true);
} else {
setMismatchVersion(false);
}
}, [appVersion, backendVersion]);
return { appVersion, mismatchVersion };
}
@@ -0,0 +1,50 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import type { GroupEnabledResult } from "@app/types/groupEnabled";
const OFFLINE_REASON_FALLBACK =
"Requires your Stirling-PDF server (currently offline)";
/**
* Desktop override: skips the network request entirely when the self-hosted
* server is confirmed offline, returning a reason string matching the tool panel.
*/
export function useGroupEnabled(group: string): GroupEnabledResult {
const { t } = useTranslation();
// Initialise synchronously so the first render already reflects offline state —
// avoids a flash where the option appears enabled before the effect runs.
// Use OFFLINE_REASON_FALLBACK directly so unavailableReason is non-null from
// the very first render when offline (t() is not available in useState initialiser).
const [result, setResult] = useState<GroupEnabledResult>(() => {
const { status } = selfHostedServerMonitor.getSnapshot();
if (status === "offline") {
return { enabled: false, unavailableReason: OFFLINE_REASON_FALLBACK };
}
return { enabled: null, unavailableReason: null };
});
useEffect(() => {
const { status } = selfHostedServerMonitor.getSnapshot();
if (status === "offline") {
setResult({
enabled: false,
unavailableReason: t(
"toolPanel.fullscreen.selfHostedOffline",
OFFLINE_REASON_FALLBACK,
),
});
return;
}
apiClient
.get<boolean>(
`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`,
)
.then((res) => setResult({ enabled: res.data, unavailableReason: null }))
.catch(() => setResult({ enabled: false, unavailableReason: null }));
}, [group, t]);
return result;
}
@@ -0,0 +1,16 @@
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
/**
* Desktop override: shared (group) signing requires self-hosted mode AND
* an authenticated session. Returns false in SaaS/local mode or when logged out.
*/
export function useGroupSigningEnabled(): boolean {
const { config } = useAppConfig();
const { isSelfHosted, isAuthenticated } = useSelfHostedAuth();
return (
isSelfHosted &&
isAuthenticated &&
config?.storageGroupSigningEnabled === true
);
}
@@ -0,0 +1,69 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { fileOpenService } from "@app/services/fileOpenService";
import { listen } from "@tauri-apps/api/event";
export function useOpenedFile() {
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const openedFilePathsRef = useRef<string[]>([]);
const clearOpenedFilePaths = useCallback(() => {
openedFilePathsRef.current = [];
setOpenedFilePaths([]);
}, []);
const consumeOpenedFilePaths = useCallback(() => {
const current = openedFilePathsRef.current;
openedFilePathsRef.current = [];
setOpenedFilePaths([]);
return current;
}, []);
useEffect(() => {
// Function to read and process files from storage
const readFilesFromStorage = async () => {
console.log("🔍 Reading files from storage...");
try {
const filePaths = await fileOpenService.getOpenedFiles();
console.log("🔍 fileOpenService.getOpenedFiles() returned:", filePaths);
if (filePaths.length > 0) {
console.log(
`✅ Found ${filePaths.length} file(s) in storage:`,
filePaths,
);
openedFilePathsRef.current = filePaths;
setOpenedFilePaths(filePaths);
}
} catch (error) {
console.error("❌ Failed to read files from storage:", error);
} finally {
setLoading(false);
}
};
// Read files on mount
readFilesFromStorage();
// Listen for files-changed events (when new files are added to storage)
let unlisten: (() => void) | undefined;
listen("files-changed", async () => {
console.log("📂 files-changed event received, re-reading storage...");
await readFilesFromStorage();
}).then((unlistenFn) => {
unlisten = unlistenFn;
});
// Cleanup function
return () => {
if (unlisten) unlisten();
};
}, []);
return {
openedFilePaths,
loading,
clearOpenedFilePaths,
consumeOpenedFilePaths,
};
}
@@ -0,0 +1,22 @@
import { useState, useEffect } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
/**
* Returns whether the app is currently in SaaS connection mode.
* Starts optimistically true (most common for desktop) to avoid tools
* being incorrectly marked unavailable during initial load.
*/
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"),
);
}, []);
return isSaaSMode;
}
@@ -0,0 +1,158 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
useSaaSBilling,
usePlanPricing,
} from "@app/contexts/SaasBillingContext";
import {
FREE_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
ENTERPRISE_PLAN_FEATURES,
} from "@app/config/planFeatures";
import type { TierLevel } from "@app/types/billing";
export interface PlanFeature {
name: string;
included: boolean;
}
export interface PlanTier {
id: TierLevel;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: string[];
isContactOnly?: boolean;
overagePrice?: number;
}
export const useSaaSPlans = () => {
const { t } = useTranslation();
const { refreshPlans } = useSaaSBilling();
const { plans, plansLoading, plansError } = usePlanPricing();
const computedPlans = useMemo<PlanTier[]>(() => {
const teamPlan = plans.get("team");
return [
{
id: "free",
name: t("plan.free.name", "Free"),
price: 0,
currency: "$",
period: t("plan.period.month", "/month"),
highlights: FREE_PLAN_FEATURES.map((f) =>
t(f.translationKey, f.defaultText),
),
features: [
{
name: t("plan.feature.pdfTools", "Basic PDF Tools"),
included: true,
},
{
name: t("plan.feature.fileSize", "File Size Limit"),
included: false,
},
{
name: t("plan.feature.automation", "Automate tool workflows"),
included: false,
},
{ name: t("plan.feature.api", "API Access"), included: false },
{
name: t("plan.feature.priority", "Priority Support"),
included: false,
},
{
name: t("plan.feature.customPricing", "Custom Pricing"),
included: false,
},
],
},
{
id: "team",
name: t("plan.team.name", "Team"),
price: teamPlan?.price || 10,
currency: teamPlan?.currency || "$",
period: t("plan.period.month", "/month"),
popular: true,
overagePrice: teamPlan?.overagePrice || 0.05,
highlights: TEAM_PLAN_FEATURES.map((f) =>
t(f.translationKey, f.defaultText),
),
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,
},
],
},
{
id: "enterprise",
name: t("plan.enterprise.name", "Enterprise"),
price: 0,
currency: "$",
period: "",
isContactOnly: true,
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,
},
],
},
];
}, [t, plans]);
return {
plans: computedPlans,
loading: plansLoading,
error: plansError,
refetch: refreshPlans,
};
};
@@ -0,0 +1,65 @@
import { useEffect } from "react";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { downloadFile } from "@app/services/downloadService";
/**
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
* Only saves files that have a localFilePath (came from disk)
* Matches WorkbenchBar button behavior: saves selected files if any, otherwise all files
*/
export function useSaveShortcut() {
const { selectors, state } = useFileState();
const { actions: fileActions } = useFileActions();
useEffect(() => {
const handleKeyDown = async (event: KeyboardEvent) => {
// Check for Ctrl+S (Windows/Linux) or Cmd+S (Mac)
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
// Get selected files or all files if nothing selected
const selectedFileIds = state.ui.selectedFileIds;
const filesToSave =
selectedFileIds.length > 0
? selectors.getFiles(selectedFileIds)
: selectors.getFiles();
const stubsToSave =
selectedFileIds.length > 0
? selectors.getStirlingFileStubs(selectedFileIds)
: selectors.getStirlingFileStubs();
if (filesToSave.length === 0) {
return;
}
// Save files (Save As for files without localFilePath)
for (let i = 0; i < filesToSave.length; i++) {
const file = filesToSave[i];
const stub = stubsToSave[i];
if (!stub) continue;
try {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: stub.localFilePath,
});
// Mark file as clean after successful save
if (result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false,
});
}
} catch (error) {
console.error(`Failed to save ${file.name}:`, error);
}
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [selectors, state.ui.selectedFileIds, fileActions]);
}
@@ -0,0 +1,47 @@
import { useState, useEffect, useRef } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
export interface SelfHostedAuthState {
isSelfHosted: boolean;
isAuthenticated: boolean;
}
/**
* Tracks whether the desktop app is in self-hosted mode with an active
* authenticated session. Refetches app config when the mode first transitions
* to selfhosted, since the jwt-available config fetch fires against the local
* bundled backend before the SetupWizard has switched the mode.
*/
export function useSelfHostedAuth(): SelfHostedAuthState {
const { refetch } = useAppConfig();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isSelfHosted, setIsSelfHosted] = useState(false);
const wasSelfHosted = useRef(false);
useEffect(() => {
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"),
);
}, []);
useEffect(() => {
if (isSelfHosted && !wasSelfHosted.current) {
void refetch();
}
wasSelfHosted.current = isSelfHosted;
}, [isSelfHosted, refetch]);
return { isSelfHosted, isAuthenticated };
}
@@ -0,0 +1,95 @@
import { useState, useEffect, useRef } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
/**
* Desktop implementation of useSelfHostedToolAvailability.
* Returns the set of tool IDs that are unavailable when the self-hosted server
* is offline (tools whose endpoints are not supported by the local bundled backend).
*
* Returns an empty set when:
* - Not in self-hosted mode
* - Self-hosted server is online
* - Local backend port is not yet known
*/
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);
toolsRef.current = tools;
useEffect(() => {
let cancelled = false;
const computeUnavailableTools = async () => {
const mode = await connectionModeService.getCurrentMode();
if (mode !== "selfhosted") {
setUnavailableIds(new Set());
return;
}
const { status } = selfHostedServerMonitor.getSnapshot();
if (status !== "offline") {
// Idle or checking — not yet confirmed offline; don't mark anything unavailable
if (!cancelled) setUnavailableIds(new Set());
return;
}
const localUrl = tauriBackendService.getBackendUrl();
if (!localUrl) {
// Local backend port not yet known; can't determine unavailable tools yet
if (!cancelled) setUnavailableIds(new Set());
return;
}
// For each tool, check whether at least one of its endpoints is supported locally
const unavailable = new Set<string>();
await Promise.all(
toolsRef.current.map(async (tool) => {
const endpoints = tool.endpoints ?? [];
if (endpoints.length === 0) return; // No endpoints → always available
const locallySupported = await Promise.all(
endpoints.map((ep) =>
endpointAvailabilityService.isEndpointSupportedLocally(
ep,
localUrl,
),
),
);
if (!locallySupported.some(Boolean)) {
unavailable.add(tool.id);
}
}),
);
if (!cancelled) setUnavailableIds(unavailable);
};
// Re-compute when server status changes
const unsubServer = selfHostedServerMonitor.subscribe(() => {
void computeUnavailableTools();
});
// Re-compute when local backend becomes healthy (port discovered)
const unsubBackend = tauriBackendService.subscribeToStatus(() => {
void computeUnavailableTools();
});
// Initial computation
void computeUnavailableTools();
return () => {
cancelled = true;
unsubServer();
unsubBackend();
};
}, []); // tools intentionally omitted — accessed via ref to avoid churn
return unavailableIds;
}
@@ -0,0 +1,18 @@
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
import type { SharingEnabledResult } from "@core/hooks/useSharingEnabled";
/**
* Desktop override: file-sharing features require self-hosted mode AND an
* authenticated session. Returns false for both in SaaS/local mode or when
* logged out.
*/
export function useSharingEnabled(): SharingEnabledResult {
const { config } = useAppConfig();
const { isSelfHosted, isAuthenticated } = useSelfHostedAuth();
const allowed = isSelfHosted && isAuthenticated;
return {
sharingEnabled: allowed && config?.storageSharingEnabled === true,
shareLinksEnabled: allowed && config?.storageShareLinksEnabled === true,
};
}
@@ -0,0 +1,75 @@
import { useState, useEffect } from "react";
import { connectionModeService } from "@app/services/connectionModeService";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { tauriBackendService } from "@app/services/tauriBackendService";
/**
* Desktop hook to check if a tool endpoint will use cloud backend
* @param endpointName - The endpoint name to check (e.g., 'ocr-pdf', 'compress-pdf')
* @returns true if the tool will use cloud credits, false otherwise
*/
export function useToolCloudStatus(endpointName?: string): boolean {
const [usesCloud, setUsesCloud] = useState(false);
useEffect(() => {
const checkCloudRouting = async () => {
if (!endpointName) {
setUsesCloud(false);
return;
}
try {
// Don't show cloud badges until backend is healthy
// This prevents showing incorrect cloud status during startup
if (!tauriBackendService.isOnline) {
setUsesCloud(false);
return;
}
// Check if in SaaS mode
const mode = await connectionModeService.getCurrentMode();
if (mode !== "saas") {
setUsesCloud(false);
return;
}
// Check if supported on SaaS first (if not, no point showing cloud badge)
const supportedOnSaaS =
await endpointAvailabilityService.isEndpointSupportedOnSaaS(
endpointName,
);
if (!supportedOnSaaS) {
// Not available on SaaS, don't show cloud badge
setUsesCloud(false);
return;
}
// Available on SaaS, check if also available locally
const supportedLocally =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl(),
);
// Show cloud badge only if SaaS supports it but local doesn't
setUsesCloud(!supportedLocally);
} catch {
setUsesCloud(false);
}
};
// Initial check
checkCloudRouting();
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === "healthy") {
checkCloudRouting();
}
});
return unsubscribe;
}, [endpointName]);
return usesCloud;
}
@@ -0,0 +1,23 @@
import { useViewer } from "@app/contexts/ViewerContext";
import { useCallback } from "react";
export function useViewerKeyCommand(): (event: KeyboardEvent) => boolean {
const { rotationActions } = useViewer();
return useCallback(
(event: KeyboardEvent): boolean => {
switch (event.key) {
case "r":
case "R":
event.preventDefault();
if (event.shiftKey) {
rotationActions.rotateBackward();
} else {
rotationActions.rotateForward();
}
return true;
}
return false;
},
[rotationActions],
);
}
@@ -0,0 +1,55 @@
import { useState, useEffect } from "react";
import { operationRouter } from "@app/services/operationRouter";
import { tauriBackendService } from "@app/services/tauriBackendService";
/**
* Desktop hook to detect if an operation will use cloud/SaaS backend
* @param endpoint - The API endpoint to check (e.g., '/api/v1/misc/compress-pdf')
* @returns true if the operation will use cloud credits, false otherwise
*/
export function useWillUseCloud(endpoint?: string): boolean {
const [willUseCloud, setWillUseCloud] = useState(false);
useEffect(() => {
const checkCloudRouting = async () => {
if (!endpoint) {
setWillUseCloud(false);
return;
}
// Don't show cloud badges until backend is healthy
// This prevents showing incorrect cloud status during startup
if (!tauriBackendService.isOnline) {
setWillUseCloud(false);
return;
}
// Check if this endpoint will route to SaaS
try {
const willRoute = await operationRouter.willRouteToSaaS(endpoint);
setWillUseCloud(willRoute);
} catch (error) {
console.error(
"[useWillUseCloud] Failed to check cloud routing for endpoint:",
endpoint,
error,
);
setWillUseCloud(false);
}
};
// Initial check
checkCloudRouting();
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === "healthy") {
checkCloudRouting();
}
});
return unsubscribe;
}, [endpoint]);
return willUseCloud;
}
@@ -0,0 +1,12 @@
import HomePage from "@app/pages/HomePage";
/**
* Desktop override of Landing.
* In desktop builds, authentication is managed entirely by AppProviders,
* the DesktopOnboardingModal, and the SignInModal — never by routing to /login.
* Always render the main app; the onboarding/sign-in modals appear on top
* when authentication is required.
*/
export default function Landing() {
return <HomePage />;
}
@@ -0,0 +1,14 @@
import { Navigate } from "react-router-dom";
/**
* Desktop override of the /login route.
* The legacy web login page must never appear in desktop builds — authentication
* is handled exclusively through the DesktopOnboardingModal and SignInModal.
* Any navigation to /login (e.g. from Spring Boot auth redirects) is intercepted
* here and immediately redirected to /.
* The sign-in modal is opened by the desktop httpErrorHandler before navigation
* occurs, so no additional dispatch is needed here.
*/
export default function Login() {
return <Navigate to="/" replace />;
}
@@ -0,0 +1,76 @@
import { ActionIcon } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
interface LoginHeaderProps {
title: string;
subtitle?: string;
centerOnly?: boolean;
onClose?: () => void;
}
/**
* Desktop override of LoginHeader.
* Renders icon + title + optional close button all in one row.
*/
export default function LoginHeader({
title,
subtitle,
centerOnly = false,
onClose,
}: LoginHeaderProps) {
const { tooltipLogo } = useLogoAssets();
return (
<div
className={`login-header${centerOnly ? " login-header-centered" : ""}`}
style={{ marginBottom: "2rem" }}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.6rem",
flex: 1,
minWidth: 0,
}}
>
<img
src={tooltipLogo}
alt="Stirling PDF"
style={{ width: 36, height: 36, flexShrink: 0 }}
/>
{title && (
<h1 className="login-title" style={{ margin: 0 }}>
{title}
</h1>
)}
</div>
{onClose && (
<ActionIcon
onClick={onClose}
radius="md"
size={32}
variant="subtle"
style={{
flexShrink: 0,
color: "var(--text-secondary)",
outline: "none",
}}
>
<CloseIcon fontSize="small" />
</ActionIcon>
)}
</div>
{subtitle && <p className="login-subtitle">{subtitle}</p>}
</div>
);
}
@@ -0,0 +1,34 @@
/**
* Desktop-specific API client using Tauri's native HTTP client
* This file overrides @core/services/apiClient.ts for desktop builds
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
*/
import type { AxiosInstance } from "axios";
import { create } from "@app/services/tauriHttpClient";
import { handleHttpError } from "@app/services/httpErrorHandler";
import { setupApiInterceptors } from "@app/services/apiClientSetup";
import { getApiBaseUrl } from "@app/services/apiClientConfig";
// Create Tauri HTTP client with default config
const apiClient = create({
baseURL: getApiBaseUrl(),
responseType: "json",
withCredentials: false, // Desktop doesn't need credentials
});
// Setup interceptors (desktop-specific auth and backend ready checks)
// Cast to AxiosInstance - Tauri client has compatible API
setupApiInterceptors(apiClient as unknown as AxiosInstance);
// ---------- Install error interceptor ----------
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
await handleHttpError(error); // Handle error (shows toast unless suppressed)
return Promise.reject(error);
},
);
// ---------- Exports ----------
export default apiClient;
@@ -0,0 +1,30 @@
import { isTauri } from "@tauri-apps/api/core";
/**
* Desktop override: Determine base URL depending on Tauri environment
*
* Priority (non-Tauri mode):
* 1. window.STIRLING_PDF_API_BASE_URL (runtime override - fixes hardcoded localhost issues)
* 2. import.meta.env.VITE_API_BASE_URL (build-time env var)
* 3. '/' (relative path - works for same-origin deployments)
*
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
* based on connection mode and backend port. This initial baseURL is overridden
* by request interceptors in apiClientSetup.ts.
*/
export function getApiBaseUrl(): string {
if (!isTauri()) {
// Runtime override to fix hardcoded localhost in builds
if (typeof window !== "undefined" && window.STIRLING_PDF_API_BASE_URL) {
return window.STIRLING_PDF_API_BASE_URL;
}
return import.meta.env.VITE_API_BASE_URL;
}
// In Tauri mode, return empty string as placeholder
// The actual URL will be set dynamically by operationRouter based on:
// - Offline mode: dynamic port from tauriBackendService
// - Server mode: configured server URL from connectionModeService
return "";
}
@@ -0,0 +1,252 @@
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
import { alert } from "@app/components/toast";
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { createBackendNotReadyError } from "@app/constants/backendErrors";
import { operationRouter } from "@app/services/operationRouter";
import { authService } from "@app/services/authService";
import { connectionModeService } from "@app/services/connectionModeService";
import {
STIRLING_SAAS_URL,
STIRLING_SAAS_BACKEND_API_URL,
} from "@app/constants/connection";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import i18n from "@app/i18n";
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
// Extended config for custom properties
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
operationName?: string;
skipBackendReadyCheck?: boolean;
skipAuthRedirect?: boolean;
_retry?: boolean;
_isSaaSRequest?: boolean;
}
/**
* Desktop-specific API interceptors
* - Reuses the core interceptors
* - Dynamically sets base URL based on connection mode
* - Adds auth token for remote server requests
* - Blocks API calls while the bundled backend is still starting
* - Handles auth token refresh on 401 errors
*/
export function setupApiInterceptors(client: AxiosInstance): void {
coreSetup(client);
// Request interceptor: Set base URL and auth headers dynamically
client.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const extendedConfig = config as ExtendedRequestConfig;
// IMPORTANT: Check backend readiness BEFORE modifying URL
// Pattern matching in shouldSkipBackendReadyCheck() needs original relative URL
const originalUrl = extendedConfig.url;
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const skipForSaaSBackend =
await operationRouter.shouldSkipBackendReadyCheck(originalUrl);
try {
// Get the appropriate base URL for this request
const baseUrl = await operationRouter.getBaseUrl(originalUrl);
// Build the full URL
if (extendedConfig.url && !extendedConfig.url.startsWith("http")) {
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
}
// Debug logging
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
// Determine if this request needs authentication
// - Local bundled backend: No auth (security disabled)
// - SaaS backend: Needs auth token
// - Self-hosted backend: Needs auth token
const isRemote = await operationRouter.isSelfHostedMode();
const isSaaSBackendRequest = baseUrl === STIRLING_SAAS_BACKEND_API_URL;
const needsAuth = isRemote || isSaaSBackendRequest;
// Tag request so error handler can identify SaaS backend errors without URL matching
extendedConfig._isSaaSRequest = isSaaSBackendRequest;
console.debug(
`[apiClientSetup] Auth check: isRemote=${isRemote}, isSaaSBackendRequest=${isSaaSBackendRequest}, needsAuth=${needsAuth}, baseUrl=${baseUrl}`,
);
if (needsAuth) {
// Enable credentials for session management
extendedConfig.withCredentials = true;
// If another request is already refreshing, wait before attaching token
await authService.awaitRefreshIfInProgress();
const token = await authService.getAuthToken();
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
console.debug(
`[apiClientSetup] Added auth token for request to: ${extendedConfig.url}`,
);
} else {
console.warn(
`[apiClientSetup] No auth token available for: ${extendedConfig.url}`,
);
}
} else {
// Local bundled backend: disable credentials (security disabled)
extendedConfig.withCredentials = false;
}
} catch (error) {
console.error("[apiClientSetup] Error in request interceptor:", error);
// Continue with request even if routing/auth logic fails
// This ensures requests aren't blocked by interceptor errors
}
// Backend readiness check (for local backend)
const isSaaS = await operationRouter.isSaaSMode();
const backendHealthy = tauriBackendService.isOnline;
const backendStatus = tauriBackendService.getBackendStatus();
const backendPort = tauriBackendService.getBackendPort();
console.debug(
`[apiClientSetup] Backend readiness check for ${extendedConfig.url}: isSaaS=${isSaaS}, skipCheck=${skipCheck}, skipForSaaSBackend=${skipForSaaSBackend}, backendHealthy=${backendHealthy}, backendStatus=${backendStatus}, backendPort=${backendPort}`,
);
if (isSaaS && !skipCheck && !skipForSaaSBackend && !backendHealthy) {
const method = (extendedConfig.method || "get").toLowerCase();
if (method !== "get") {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: "error",
title: i18n.t("backendHealth.offline", "Backend Offline"),
body: i18n.t(
"backendHealth.wait",
"Please wait for the backend to finish launching and try again.",
),
isPersistentPopup: false,
});
}
}
return Promise.reject(createBackendNotReadyError());
}
return extendedConfig;
},
(error) => Promise.reject(error),
);
// Response interceptor: Handle auth errors and update credits from headers
client.interceptors.response.use(
async (response) => {
// Check for credit balance update in response headers
// Backend includes X-Credits-Remaining header after operations that consume credits
const creditsHeader = response.headers["x-credits-remaining"];
if (creditsHeader !== undefined && creditsHeader !== null) {
const creditsRemaining = parseInt(creditsHeader, 10);
if (!isNaN(creditsRemaining)) {
// Dispatch event with new balance for immediate update
window.dispatchEvent(
new CustomEvent("credits:updated", {
detail: { creditsRemaining },
}),
);
}
}
return response;
},
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;
const requestUrl = String(originalRequest?.url || "");
const isAuthProbeRequest = requestUrl.includes("/api/v1/auth/me");
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
// `/auth/me` is used as a probe by session bootstrap; refreshing here can
// create recursion (refresh -> save token -> jwt-available -> /auth/me).
if (isAuthProbeRequest) {
return Promise.reject(error);
}
if (typeof window !== "undefined") {
console.warn(
"[apiClientSetup] 401 on path:",
window.location.pathname,
"url:",
originalRequest.url,
);
}
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
}
// If no Authorization header was sent, the user was never authenticated —
// the 401 is expected (e.g. endpoint availability checks when not signed in).
// Don't attempt a refresh or open the sign-in modal in that case.
if (!originalRequest.headers.Authorization) {
return Promise.reject(error);
}
originalRequest._retry = true;
console.debug(
`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`,
);
const isRemote = await operationRouter.isSelfHostedMode();
let refreshed = false;
if (isRemote) {
// Self-hosted mode: use Spring Boot refresh endpoint
const serverConfig = await connectionModeService.getServerConfig();
if (serverConfig) {
refreshed = await authService.refreshToken(serverConfig.url);
}
} else {
// SaaS mode: use Supabase refresh endpoint
refreshed = await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
}
if (refreshed) {
// Retry the original request with new token
const token = await authService.getAuthToken();
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!`,
);
}
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 } }),
);
}
// Handle 403 Forbidden - unauthorized access
if (error.response?.status === 403) {
alert({
alertType: "error",
title: i18n.t("auth.accessDenied", "Access Denied"),
body: i18n.t(
"auth.insufficientPermissions",
"You do not have permission to perform this action.",
),
isPersistentPopup: false,
});
}
return Promise.reject(error);
},
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import { invoke } from "@tauri-apps/api/core";
const TOKEN_KEY = "stirling_jwt";
/**
* Read auth token from any available source (Tauri store or localStorage).
* Kept separate to avoid circular dependencies between auth and backend services.
*/
export async function getAuthTokenFromAnySource(): Promise<string | null> {
// Try Tauri store first
try {
const token = await invoke<string | null>("get_auth_token");
if (token) {
return token;
}
} catch (error) {
console.error(
"[Desktop AuthTokenStore] Failed to read from Tauri store:",
error,
);
}
// Fallback to localStorage
try {
return localStorage.getItem(TOKEN_KEY);
} catch (error) {
console.error(
"[Desktop AuthTokenStore] Failed to read from localStorage:",
error,
);
return null;
}
}

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