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
@@ -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;
}