mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -0,0 +1,51 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { STIRLING_SAAS_URL, SUPABASE_KEY } from "@app/constants/connection";
|
||||
|
||||
/**
|
||||
* Supabase client for desktop application
|
||||
* Used to call Supabase edge functions for billing and other SaaS features
|
||||
*
|
||||
* Note: Desktop uses authService for authentication (JWT stored in Tauri secure store),
|
||||
* but this client is needed for calling Supabase edge functions like get-usage-billing
|
||||
*/
|
||||
|
||||
if (!STIRLING_SAAS_URL) {
|
||||
console.warn(
|
||||
"[Desktop Supabase] VITE_SAAS_SERVER_URL not configured - SaaS features will not work",
|
||||
);
|
||||
}
|
||||
|
||||
if (!SUPABASE_KEY) {
|
||||
console.warn(
|
||||
"[Desktop Supabase] VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY not configured - SaaS features will not work",
|
||||
);
|
||||
}
|
||||
|
||||
export const supabase = createClient(
|
||||
STIRLING_SAAS_URL || "",
|
||||
SUPABASE_KEY || "",
|
||||
{
|
||||
auth: {
|
||||
persistSession: false, // Desktop manages auth via authService + Tauri secure store
|
||||
autoRefreshToken: false, // Desktop manually refreshes tokens via authService
|
||||
detectSessionInUrl: false, // Desktop uses deep links, not URL hash fragments
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,322 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
|
||||
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
|
||||
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
|
||||
import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal";
|
||||
import { SignInModal } from "@app/components/SignInModal";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import { ToolActionsContext } from "@app/contexts/ToolActionsContext";
|
||||
import { useFirstLaunchCheck } from "@app/hooks/useFirstLaunchCheck";
|
||||
import { useBackendInitializer } from "@app/hooks/useBackendInitializer";
|
||||
import { DESKTOP_DEFAULT_APP_CONFIG } from "@app/config/defaultAppConfig";
|
||||
import {
|
||||
connectionModeService,
|
||||
JWT_EXPIRED_PROMPTED_KEY,
|
||||
} from "@app/services/connectionModeService";
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
|
||||
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
|
||||
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
|
||||
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
|
||||
|
||||
// Common tool endpoints to preload for faster first-use
|
||||
const COMMON_TOOL_ENDPOINTS = [
|
||||
"/api/v1/misc/compress-pdf",
|
||||
"/api/v1/general/merge-pdfs",
|
||||
"/api/v1/general/split-pages",
|
||||
"/api/v1/convert/pdf/img",
|
||||
"/api/v1/convert/img/pdf",
|
||||
"/api/v1/general/rotate-pdf",
|
||||
"/api/v1/misc/add-watermark",
|
||||
"/api/v1/security/add-password",
|
||||
"/api/v1/security/remove-password",
|
||||
"/api/v1/general/extract-pages",
|
||||
];
|
||||
|
||||
/**
|
||||
* Desktop application providers
|
||||
* Wraps proprietary providers and adds desktop-specific configuration
|
||||
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
|
||||
* - Shows setup wizard on first launch
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||
const [connectionMode, setConnectionMode] = useState<
|
||||
"saas" | "selfhosted" | "local" | null
|
||||
>(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [pendingSignIn, setPendingSignIn] = useState(false);
|
||||
// Prevent first-launch setup from running twice when connectionMode state update re-triggers the effect
|
||||
const firstLaunchInitiated = useRef(false);
|
||||
// Key incremented on every connection mode change after initial load — forces SaaS provider
|
||||
// tree to remount without a full page reload (avoids Windows WebView2 freeze on window.location.reload()).
|
||||
const [appKey, setAppKey] = useState(0);
|
||||
const hasLoadedInitialMode = useRef(false);
|
||||
|
||||
// Load connection mode on mount and subscribe to future changes
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then((mode) => {
|
||||
setConnectionMode(mode);
|
||||
hasLoadedInitialMode.current = true;
|
||||
});
|
||||
|
||||
const unsub = connectionModeService.subscribeToModeChanges((config) => {
|
||||
setConnectionMode(config.mode);
|
||||
// Remount the SaaS provider tree when transitioning between saas/local modes so
|
||||
// Supabase client state is reset without a full page reload (avoids the Windows
|
||||
// WebView2 freeze that window.location.reload() causes during an OAuth flow).
|
||||
// Switching TO selfhosted skips the remount — self-hosted mode doesn't use the
|
||||
// SaaS providers and remounting mid-wizard resets authChecked, navigating away.
|
||||
// Switching FROM selfhosted TO saas DOES trigger a remount (mode !== 'selfhosted')
|
||||
// which is intentional — the SaaS provider tree needs fresh state after login.
|
||||
if (hasLoadedInitialMode.current && config.mode !== "selfhosted") {
|
||||
setAppKey((k) => k + 1);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait until connection mode is loaded before checking auth
|
||||
if (connectionMode === null) return;
|
||||
|
||||
if (!isFirstLaunch && setupComplete) {
|
||||
if (connectionMode === "local") {
|
||||
// Even in local mode, check for a valid JWT — on Windows, the OAuth callback
|
||||
// can complete without switchToSaaS() being called (race condition), leaving
|
||||
// LOCAL_MODE_STORAGE_KEY set while the user has a valid session. Upgrade to
|
||||
// SaaS mode automatically so credits/billing/team features work correctly.
|
||||
authService
|
||||
.isAuthenticated()
|
||||
.then(async (isAuth) => {
|
||||
if (isAuth) {
|
||||
await connectionModeService
|
||||
.switchToSaaS(STIRLING_SAAS_URL)
|
||||
.catch(console.error);
|
||||
setConnectionMode("saas");
|
||||
}
|
||||
})
|
||||
.finally(() => setAuthChecked(true));
|
||||
} else {
|
||||
authService
|
||||
.isAuthenticated()
|
||||
.then(async (isAuth) => {
|
||||
if (!isAuth) {
|
||||
const cfg = await connectionModeService
|
||||
.getCurrentConfig()
|
||||
.catch(() => null);
|
||||
if (!cfg?.lock_connection_mode) {
|
||||
// JWT expired — fall back to local so local tools still work.
|
||||
await connectionModeService
|
||||
.switchToLocal()
|
||||
.catch(console.error);
|
||||
setConnectionMode("local");
|
||||
// Show sign-in modal once per expiry cycle. If the user dismisses
|
||||
// without signing in the flag stays set and we won't prompt again
|
||||
// until they successfully sign in (which clears the flag).
|
||||
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
|
||||
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
|
||||
setPendingSignIn(true);
|
||||
}
|
||||
}
|
||||
// Locked deployments stay in their configured mode — user can sign in
|
||||
// via Settings when they're ready.
|
||||
}
|
||||
})
|
||||
.catch(async () => {
|
||||
const cfg = await connectionModeService
|
||||
.getCurrentConfig()
|
||||
.catch(() => null);
|
||||
if (!cfg?.lock_connection_mode) {
|
||||
await connectionModeService.switchToLocal().catch(console.error);
|
||||
setConnectionMode("local");
|
||||
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
|
||||
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
|
||||
setPendingSignIn(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => setAuthChecked(true));
|
||||
}
|
||||
} else if (isFirstLaunch && !setupComplete) {
|
||||
// Guard against re-running when setConnectionMode triggers this effect.
|
||||
if (firstLaunchInitiated.current) return;
|
||||
firstLaunchInitiated.current = true;
|
||||
connectionModeService
|
||||
.getCurrentConfig()
|
||||
.then(async (cfg) => {
|
||||
if (cfg.lock_connection_mode && cfg.server_config?.url) {
|
||||
// Locked provisioned deployment — do NOT switch to local (would clear server_config
|
||||
// from the store). Show onboarding normally; the sign-in slide handles locked auth.
|
||||
// Still start the local backend so local tools work while the user signs in.
|
||||
await tauriBackendService.startBackend().catch(console.error);
|
||||
setConnectionMode("selfhosted");
|
||||
} else {
|
||||
// Normal first launch — auto-enter local mode.
|
||||
// The onboarding carousel + sign-in slide will be shown inside the main app.
|
||||
await connectionModeService.switchToLocal();
|
||||
await tauriBackendService.startBackend();
|
||||
setConnectionMode("local");
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setAuthChecked(true));
|
||||
}
|
||||
}, [isFirstLaunch, setupComplete, connectionMode]);
|
||||
|
||||
// Initialize backend health monitoring for self-hosted mode
|
||||
useEffect(() => {
|
||||
if (connectionMode !== "selfhosted") {
|
||||
// Stop the monitor whenever we leave selfhosted mode so the dot resets.
|
||||
selfHostedServerMonitor.stop();
|
||||
return;
|
||||
}
|
||||
if (setupComplete && !isFirstLaunch) {
|
||||
void tauriBackendService.initializeExternalBackend();
|
||||
connectionModeService.getServerConfig().then((cfg) => {
|
||||
if (cfg?.url) {
|
||||
selfHostedServerMonitor.start(cfg.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
selfHostedServerMonitor.stop();
|
||||
};
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Initialize monitoring for bundled backend (already started in Rust)
|
||||
// This sets up port detection and health checks
|
||||
const shouldMonitorBackend =
|
||||
setupComplete &&
|
||||
!isFirstLaunch &&
|
||||
(connectionMode === "saas" || connectionMode === "local");
|
||||
useBackendInitializer(shouldMonitorBackend);
|
||||
|
||||
// Preload endpoint availability for the local bundled backend.
|
||||
// SaaS mode: triggers when the bundled backend reports healthy.
|
||||
// Self-hosted mode: triggers when the local bundled backend port is discovered
|
||||
// (so useSelfHostedToolAvailability can use the cache instead of making
|
||||
// individual requests per-tool when the remote server goes offline).
|
||||
const shouldPreloadLocalEndpoints =
|
||||
(setupComplete && !isFirstLaunch && connectionMode === "saas") ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === "local") ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === "selfhosted");
|
||||
useEffect(() => {
|
||||
if (!shouldPreloadLocalEndpoints) return;
|
||||
|
||||
const tryPreload = () => {
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
if (!backendUrl) return;
|
||||
// tauriBackendService.isOnline now always reflects the local backend.
|
||||
// Wait for it to be healthy before preloading in both modes.
|
||||
if (!tauriBackendService.isOnline) return;
|
||||
console.debug(
|
||||
"[AppProviders] Preloading common tool endpoints for local backend",
|
||||
);
|
||||
void endpointAvailabilityService.preloadEndpoints(
|
||||
COMMON_TOOL_ENDPOINTS,
|
||||
backendUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus(() =>
|
||||
tryPreload(),
|
||||
);
|
||||
tryPreload();
|
||||
return unsubscribe;
|
||||
}, [shouldPreloadLocalEndpoints, connectionMode]);
|
||||
|
||||
// Dispatch sign-in modal after authChecked so SignInModal's listener is registered.
|
||||
// (Child effects run before parent effects, so this fires after SignInModal mounts.)
|
||||
// detail.locked is always false here: setPendingSignIn(true) is only called inside
|
||||
// `if (!cfg?.lock_connection_mode)` branches above, so locked deployments never set
|
||||
// pendingSignIn and therefore never reach this dispatch.
|
||||
useEffect(() => {
|
||||
if (!authChecked || !pendingSignIn) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }),
|
||||
);
|
||||
setPendingSignIn(false);
|
||||
}, [authChecked, pendingSignIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
currentWindow
|
||||
.show()
|
||||
.then(() => currentWindow.unminimize().catch(() => {}))
|
||||
.then(() => currentWindow.setFocus().catch(() => {}))
|
||||
.then(() => currentWindow.requestUserAttention(1).catch(() => {}))
|
||||
.catch(() => {});
|
||||
}, [authChecked]);
|
||||
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000,
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: "non-blocking",
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<div style={{ minHeight: "100vh" }} />
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal app flow
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000,
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: "non-blocking",
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<ToolActionsContext.Provider
|
||||
value={{
|
||||
onEndpointUnavailableClick: () =>
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
|
||||
}}
|
||||
>
|
||||
<SaaSTeamProvider key={appKey}>
|
||||
<SaasBillingProvider>
|
||||
<SaaSCheckoutProvider>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
<CreditModalBootstrap />
|
||||
{children}
|
||||
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
|
||||
<DesktopOnboardingModal />
|
||||
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
|
||||
<SignInModal />
|
||||
</SaaSCheckoutProvider>
|
||||
</SaasBillingProvider>
|
||||
</SaaSTeamProvider>
|
||||
</ToolActionsContext.Provider>
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Tooltip, useMantineTheme, rem } from "@mantine/core";
|
||||
import { useBackendHealth } from "@app/hooks/useBackendHealth";
|
||||
|
||||
interface BackendHealthIndicatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { status, isOnline, checkHealth } = useBackendHealth();
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (status === "starting") {
|
||||
return t("backendHealth.checking", "Checking backend status...");
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
return t("backendHealth.online", "Backend Online");
|
||||
}
|
||||
|
||||
return t("backendHealth.offline", "Backend Offline");
|
||||
}, [status, isOnline, t]);
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
if (status === "starting") {
|
||||
return theme.colors.yellow?.[5] ?? "#fcc419";
|
||||
}
|
||||
if (isOnline) {
|
||||
return theme.colors.green?.[5] ?? "#37b24d";
|
||||
}
|
||||
return theme.colors.red?.[6] ?? "#e03131";
|
||||
}, [
|
||||
status,
|
||||
isOnline,
|
||||
theme.colors.green,
|
||||
theme.colors.red,
|
||||
theme.colors.yellow,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
checkHealth();
|
||||
}
|
||||
},
|
||||
[checkHealth],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} position="left" offset={12} withArrow withinPortal>
|
||||
<Box
|
||||
component="span"
|
||||
className={className ? `${className}` : undefined}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onClick={checkHealth}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
width: rem(12),
|
||||
height: rem(12),
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dotColor,
|
||||
boxShadow: "var(--status-dot-ring)",
|
||||
cursor: "pointer",
|
||||
display: "inline-block",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Card, Badge, Button, Text, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
connectionModeService,
|
||||
ConnectionConfig,
|
||||
} from "@app/services/connectionModeService";
|
||||
import { authService, UserInfo } from "@app/services/authService";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
|
||||
export const ConnectionSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<ConnectionConfig | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load current config on mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(currentConfig);
|
||||
|
||||
if (
|
||||
currentConfig.mode === "saas" ||
|
||||
currentConfig.mode === "selfhosted"
|
||||
) {
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
|
||||
const unsubscribe =
|
||||
connectionModeService.subscribeToModeChanges(loadConfig);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Save server URL before clearing so user can easily reconnect (self-hosted only)
|
||||
if (config?.mode === "selfhosted" && config?.server_config?.url) {
|
||||
localStorage.setItem("server_url", config.server_config.url);
|
||||
}
|
||||
await authService.logout();
|
||||
// Always switch to local after logout so the app remains usable
|
||||
await connectionModeService.switchToLocal();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Clear URL to home page so we don't return to settings after re-login
|
||||
window.history.replaceState({}, "", "/");
|
||||
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||
// connectionModeService subscription when mode changes to local.
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignIn = () => {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return <Text>{t("common.loading", "Loading...")}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>
|
||||
{t("settings.connection.title", "Connection Mode")}
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
config.mode === "saas"
|
||||
? "blue"
|
||||
: config.mode === "local"
|
||||
? "white"
|
||||
: "green"
|
||||
}
|
||||
variant="light"
|
||||
>
|
||||
{config.mode === "saas"
|
||||
? t("settings.connection.mode.saas", "Stirling Cloud")
|
||||
: config.mode === "local"
|
||||
? t("settings.connection.mode.local", "Local Only")
|
||||
: t("settings.connection.mode.selfhosted", "Self-Hosted")}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{config.mode === "local" && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"settings.connection.localDescription",
|
||||
"You are using the local backend without an account. Some tools requiring cloud processing or a self-hosted server are unavailable.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(config.mode === "saas" || config.mode === "selfhosted") &&
|
||||
config.server_config && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("settings.connection.server", "Server")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{config.mode === "saas"
|
||||
? "stirling.com"
|
||||
: config.server_config.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{userInfo && (
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("settings.connection.user", "Logged in as")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{userInfo.username}
|
||||
{userInfo.email && ` (${userInfo.email})`}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group mt="md">
|
||||
{config.mode === "local" ? (
|
||||
<Button onClick={handleSignIn} color="blue" variant="light">
|
||||
{t("settings.connection.signIn", "Sign In")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
color="red"
|
||||
variant="light"
|
||||
disabled={loading}
|
||||
>
|
||||
{t("settings.connection.logout", "Log Out")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from "react";
|
||||
import { useBanner } from "@app/contexts/BannerContext";
|
||||
import { DefaultAppBanner } from "@app/components/shared/DefaultAppBanner";
|
||||
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
|
||||
import { TeamInvitationBanner } from "@app/components/shared/TeamInvitationBanner";
|
||||
import { SelfHostedOfflineBanner } from "@app/components/shared/SelfHostedOfflineBanner";
|
||||
|
||||
export function DesktopBannerInitializer() {
|
||||
const { setBanner } = useBanner();
|
||||
|
||||
useEffect(() => {
|
||||
setBanner(
|
||||
<>
|
||||
<SelfHostedOfflineBanner />
|
||||
<TeamInvitationBanner />
|
||||
<UpgradeBanner />
|
||||
<DefaultAppBanner />
|
||||
</>,
|
||||
);
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [setBanner]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useBackendHealth } from "@app/hooks/useBackendHealth";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
/**
|
||||
* Desktop-only bridge that refetches the app config once the bundled backend
|
||||
* becomes healthy (and whenever it restarts). Keeps the UI responsive by using
|
||||
* default config until the real config is available.
|
||||
*/
|
||||
export function DesktopConfigSync() {
|
||||
const { status } = useBackendHealth();
|
||||
const { refetch } = useAppConfig();
|
||||
const previousStatus = useRef(status);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "healthy" && previousStatus.current !== "healthy") {
|
||||
refetch();
|
||||
}
|
||||
previousStatus.current = status;
|
||||
}, [status, refetch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Modal, Stack, Group, Button, ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
|
||||
import { SetupWizard } from "@app/components/SetupWizard";
|
||||
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
const ONBOARDING_KEY = "stirling-desktop-onboarding-seen";
|
||||
|
||||
const SIGN_IN_GRADIENT: [string, string] = ["#3B82F6", "#7C3AED"];
|
||||
|
||||
/**
|
||||
* Desktop-specific onboarding modal.
|
||||
* Shown on first launch: welcome slide → sign-in slide.
|
||||
* Replaces the core onboarding (which targets server/admin users).
|
||||
*/
|
||||
export function DesktopOnboardingModal() {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(
|
||||
() => !localStorage.getItem(ONBOARDING_KEY),
|
||||
);
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const dismissFinal = () => {
|
||||
localStorage.setItem(ONBOARDING_KEY, "true");
|
||||
setVisible(false);
|
||||
// If the user dismissed the sign-in slide without authenticating, fall back to local mode
|
||||
// so the app is usable without a server connection.
|
||||
connectionModeService.switchToLocal().catch(console.error);
|
||||
};
|
||||
|
||||
// X on slide 0 advances to sign-in slide rather than dismissing entirely
|
||||
const handleClose = () => {
|
||||
if (step === 0) {
|
||||
setStep(1);
|
||||
} else {
|
||||
dismissFinal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
localStorage.setItem(ONBOARDING_KEY, "true");
|
||||
setVisible(false);
|
||||
// No reload needed — AppProviders subscribes to connectionModeService and remounts
|
||||
// the SaaS provider tree when mode changes, avoiding the Windows WebView2 freeze
|
||||
// that window.location.reload() causes during a backgrounded OAuth flow.
|
||||
};
|
||||
|
||||
// Call WelcomeSlide as a data factory (not a component render) — memoised so it
|
||||
// isn't reconstructed on every render while the modal is open.
|
||||
const welcomeSlide = useMemo(() => WelcomeSlide(), []);
|
||||
const totalSteps = 2;
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={visible}
|
||||
onClose={handleClose}
|
||||
closeOnClickOutside={step === 1}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: "hidden",
|
||||
border: "none",
|
||||
background: "var(--bg-surface)",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={0}
|
||||
className={styles.modalContent}
|
||||
style={{
|
||||
height: "100%",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Hero section — gradient changes per slide */}
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={
|
||||
step === 0
|
||||
? welcomeSlide.background.gradientStops
|
||||
: SIGN_IN_GRADIENT
|
||||
}
|
||||
circles={welcomeSlide.background.circles}
|
||||
isActive
|
||||
slideKey={step === 0 ? "desktop-welcome" : "desktop-sign-in"}
|
||||
/>
|
||||
<ActionIcon
|
||||
onClick={handleClose}
|
||||
radius="md"
|
||||
size={36}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
color: "white",
|
||||
backdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.3)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<div className={styles.heroLogo} key={`logo-${step}`}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{step === 0 ? (
|
||||
<LocalIcon
|
||||
icon="rocket-launch"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
) : (
|
||||
<LocalIcon
|
||||
icon="login"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body section */}
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}
|
||||
>
|
||||
{step === 0 ? (
|
||||
// Welcome slide
|
||||
<Stack gap={16}>
|
||||
<div className={`${styles.title} ${styles.titleText}`}>
|
||||
{welcomeSlide.title}
|
||||
</div>
|
||||
<div className={styles.bodyText}>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{welcomeSlide.body}
|
||||
</div>
|
||||
<style>{`.${styles.bodyCopyInner} strong { color: var(--onboarding-title); font-weight: 600; }`}</style>
|
||||
</div>
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
|
||||
<div className={styles.buttonContainer}>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={() => setStep(1)}
|
||||
styles={{
|
||||
root: {
|
||||
background: "var(--onboarding-primary-button-bg)",
|
||||
color: "var(--onboarding-primary-button-text)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("onboarding.buttons.next", "Next →")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
// Sign-in slide
|
||||
<Stack gap={12}>
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
|
||||
<SetupWizard
|
||||
noLayout
|
||||
onComplete={handleComplete}
|
||||
onClose={dismissFinal}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useSaveShortcut } from "@app/hooks/useSaveShortcut";
|
||||
import { useExitWarning } from "@app/hooks/useExitWarning";
|
||||
|
||||
/**
|
||||
* Desktop-only component that sets up keyboard shortcuts and exit warnings
|
||||
* - Ctrl/Cmd+S to save selected files
|
||||
* - Warning on app exit if unsaved files
|
||||
* Renders nothing, just sets up the listeners
|
||||
*/
|
||||
export function SaveShortcutListener() {
|
||||
useSaveShortcut();
|
||||
useExitWarning();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
|
||||
import buildLoginSlides from "@app/components/shared/loginSlides";
|
||||
import styles from "@app/routes/authShared/AuthLayout.module.css";
|
||||
import { useLogoVariant } from "@app/hooks/useLogoVariant";
|
||||
|
||||
interface DesktopAuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(
|
||||
() => buildLoginSlides(logoVariant, t),
|
||||
[logoVariant, t],
|
||||
);
|
||||
|
||||
// Force light mode on auth pages
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
const previousColorScheme = htmlElement.getAttribute(
|
||||
"data-mantine-color-scheme",
|
||||
);
|
||||
|
||||
// Set light mode
|
||||
htmlElement.setAttribute("data-mantine-color-scheme", "light");
|
||||
|
||||
// Cleanup: restore previous theme when leaving auth pages
|
||||
return () => {
|
||||
if (previousColorScheme) {
|
||||
htmlElement.setAttribute(
|
||||
"data-mantine-color-scheme",
|
||||
previousColorScheme,
|
||||
);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>{children}</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel
|
||||
imageSlides={imageSlides}
|
||||
initialSeconds={5}
|
||||
slideSeconds={8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { authService, UserInfo } from "@app/services/authService";
|
||||
import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import "@app/components/SetupWizard/desktopOAuth.css";
|
||||
|
||||
type KnownProviderId =
|
||||
| "google"
|
||||
| "github"
|
||||
| "keycloak"
|
||||
| "azure"
|
||||
| "apple"
|
||||
| "oidc";
|
||||
export type OAuthProviderId = KnownProviderId | string;
|
||||
|
||||
export interface DesktopSSOProvider {
|
||||
id: OAuthProviderId;
|
||||
path?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface DesktopOAuthButtonsProps {
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onError: (error: string) => void;
|
||||
isDisabled: boolean;
|
||||
serverUrl: string;
|
||||
providers: DesktopSSOProvider[];
|
||||
mode?: "saas" | "selfHosted";
|
||||
}
|
||||
|
||||
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
onOAuthSuccess,
|
||||
onError,
|
||||
isDisabled,
|
||||
serverUrl,
|
||||
providers,
|
||||
mode = "saas",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
|
||||
const handleOAuthLogin = async (provider: DesktopSSOProvider) => {
|
||||
// Prevent concurrent OAuth attempts
|
||||
if (oauthLoading || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOauthLoading(true);
|
||||
|
||||
// Build callback page HTML with translations and dark mode support
|
||||
const successHtml = buildOAuthCallbackHtml({
|
||||
title: t("oauth.success.title", "Authentication Successful"),
|
||||
message: t(
|
||||
"oauth.success.message",
|
||||
"You can close this window and return to Stirling PDF.",
|
||||
),
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const errorHtml = buildOAuthCallbackHtml({
|
||||
title: t("oauth.error.title", "Authentication Failed"),
|
||||
message: t(
|
||||
"oauth.error.message",
|
||||
"Authentication was not successful. You can close this window and try again.",
|
||||
),
|
||||
isError: true,
|
||||
errorPlaceholder: true, // {error} will be replaced by Rust
|
||||
});
|
||||
|
||||
const normalizedServer = serverUrl.replace(/\/+$/, "");
|
||||
const usingSupabaseFlow =
|
||||
mode === "saas" ||
|
||||
normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, "");
|
||||
const userInfo = usingSupabaseFlow
|
||||
? await authService.loginWithOAuth(
|
||||
provider.id,
|
||||
serverUrl,
|
||||
successHtml,
|
||||
errorHtml,
|
||||
)
|
||||
: await authService.loginWithSelfHostedOAuth(
|
||||
provider.path || provider.id,
|
||||
serverUrl,
|
||||
);
|
||||
|
||||
// Call the onOAuthSuccess callback to complete setup
|
||||
await onOAuthSuccess(userInfo);
|
||||
} catch (error) {
|
||||
console.error("OAuth login failed:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t(
|
||||
"setup.login.error.oauthFailed",
|
||||
"OAuth login failed. Please try again.",
|
||||
);
|
||||
|
||||
onError(errorMessage);
|
||||
setOauthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerConfig: Record<
|
||||
KnownProviderId,
|
||||
{ label: string; file: string }
|
||||
> = {
|
||||
google: { label: "Google", file: "google.svg" },
|
||||
github: { label: "GitHub", file: "github.svg" },
|
||||
keycloak: { label: "Keycloak", file: "keycloak.svg" },
|
||||
azure: { label: "Microsoft", file: "microsoft.svg" },
|
||||
apple: { label: "Apple", file: "apple.svg" },
|
||||
oidc: { label: "OpenID", file: "oidc.svg" },
|
||||
};
|
||||
const isKnownProvider = (id: OAuthProviderId): id is KnownProviderId =>
|
||||
(id as KnownProviderId) in providerConfig;
|
||||
const GENERIC_PROVIDER_ICON = "oidc.svg";
|
||||
|
||||
console.log("[DesktopOAuthButtons] Received providers:", providers);
|
||||
console.log("[DesktopOAuthButtons] Mode:", mode, "Server URL:", serverUrl);
|
||||
|
||||
if (providers.length === 0) {
|
||||
console.warn(
|
||||
"[DesktopOAuthButtons] No providers to display, returning null",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Desktop always uses its own styling classes (independent of web)
|
||||
return (
|
||||
<div className="oauth-container-vertical-desktop">
|
||||
{providers
|
||||
.filter(
|
||||
(providerConfigEntry) =>
|
||||
providerConfigEntry && providerConfigEntry.id,
|
||||
)
|
||||
.map((providerEntry) => {
|
||||
const iconConfig = isKnownProvider(providerEntry.id)
|
||||
? providerConfig[providerEntry.id]
|
||||
: undefined;
|
||||
const label =
|
||||
providerEntry.label ||
|
||||
iconConfig?.label ||
|
||||
(providerEntry.id
|
||||
? providerEntry.id.charAt(0).toUpperCase() +
|
||||
providerEntry.id.slice(1)
|
||||
: t("setup.login.sso", "Single Sign-On"));
|
||||
return (
|
||||
<button
|
||||
key={providerEntry.id}
|
||||
onClick={() => handleOAuthLogin(providerEntry)}
|
||||
disabled={isDisabled || oauthLoading}
|
||||
className="oauth-button-vertical-desktop"
|
||||
title={label}
|
||||
>
|
||||
<span className="oauth-button-left-desktop">
|
||||
<span className="oauth-icon-wrapper-desktop">
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${iconConfig?.file || GENERIC_PROVIDER_ICON}`}
|
||||
alt={label}
|
||||
className="oauth-icon-tiny-desktop"
|
||||
/>
|
||||
</span>
|
||||
<span className="oauth-button-text-desktop">{label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{oauthLoading && (
|
||||
<p
|
||||
style={{
|
||||
margin: "0.5rem 0",
|
||||
fontSize: "0.875rem",
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"setup.login.oauthPending",
|
||||
"Opening browser for authentication...",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
|
||||
import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink";
|
||||
import { UserInfo } from "@app/services/authService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
|
||||
interface SaaSLoginScreenProps {
|
||||
serverUrl: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onSelfHostedClick: () => void;
|
||||
onSwitchToSignup: () => void;
|
||||
onSkipSignIn?: () => void;
|
||||
onClose?: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
serverUrl,
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
onSelfHostedClick,
|
||||
onSwitchToSignup,
|
||||
onSkipSignIn,
|
||||
onClose,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailPasswordSubmit = async () => {
|
||||
// Validation
|
||||
if (!email.trim()) {
|
||||
setValidationError(
|
||||
t("setup.login.error.emptyEmail", "Please enter your email"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(
|
||||
t("setup.login.error.emptyPassword", "Please enter your password"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(email.trim(), password);
|
||||
};
|
||||
|
||||
const handleOAuthError = (errorMessage: string) => {
|
||||
setValidationError(errorMessage);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t("setup.saas.title", "Sign in to Stirling Cloud")}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
<DesktopOAuthButtons
|
||||
onOAuthSuccess={onOAuthSuccess}
|
||||
onError={handleOAuthError}
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
mode="saas"
|
||||
providers={[{ id: "google" }, { id: "github" }]}
|
||||
/>
|
||||
|
||||
<DividerWithText
|
||||
text={t("setup.login.orContinueWith", "Or continue with email")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={(value) => {
|
||||
setEmail(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
onSubmit={handleEmailPasswordSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t("setup.login.submit", "Login")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="navigation-link-container"
|
||||
style={{ marginTop: "0.5rem", textAlign: "right" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
onSwitchToSignup();
|
||||
}}
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t("signup.signUp", "Sign Up")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
||||
|
||||
{onSkipSignIn && (
|
||||
<div
|
||||
className="navigation-link-container"
|
||||
style={{ marginTop: "0.5rem", textAlign: "center" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkipSignIn}
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t("setup.login.skipSignIn", "Continue without signing in")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
import {
|
||||
useSignupFormValidation,
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { authService } from "@app/services/authService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
|
||||
interface SaaSSignupScreenProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
|
||||
loading,
|
||||
error,
|
||||
onLogin: _onLogin,
|
||||
onSwitchToLogin: _onSwitchToLogin,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>(
|
||||
{},
|
||||
);
|
||||
const [signupSuccessMessage, setSignupSuccessMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isSignupSubmitting, setIsSignupSubmitting] = useState(false);
|
||||
const { validateSignupForm } = useSignupFormValidation();
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
const handleSignupSubmit = async () => {
|
||||
setValidationError(null);
|
||||
setSignupSuccessMessage(null);
|
||||
setSignupFieldErrors({});
|
||||
|
||||
const validation = validateSignupForm(email, password, confirmPassword);
|
||||
if (!validation.isValid) {
|
||||
setValidationError(validation.error);
|
||||
setSignupFieldErrors(validation.fieldErrors || {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSignupSubmitting(true);
|
||||
await authService.signUpSaas(email.trim(), password);
|
||||
setSignupSuccessMessage(
|
||||
t(
|
||||
"signup.checkEmailConfirmation",
|
||||
"Check your email for a confirmation link to complete your registration.",
|
||||
),
|
||||
);
|
||||
setSignupFieldErrors({});
|
||||
setValidationError(null);
|
||||
} catch (err) {
|
||||
setSignupSuccessMessage(null);
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("signup.unexpectedError", { message: "Unknown error" });
|
||||
setValidationError(message);
|
||||
} finally {
|
||||
setIsSignupSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t("signup.title", "Create an account")}
|
||||
subtitle={t("signup.subtitle", "Join Stirling PDF")}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
{signupSuccessMessage && (
|
||||
<div className="success-message">
|
||||
<p className="success-message-text">{signupSuccessMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SignupForm
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
setEmail={(value) => {
|
||||
setEmail(value);
|
||||
setValidationError(null);
|
||||
setSignupFieldErrors({});
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
setSignupFieldErrors({});
|
||||
}}
|
||||
setConfirmPassword={(value) => {
|
||||
setConfirmPassword(value);
|
||||
setValidationError(null);
|
||||
setSignupFieldErrors({});
|
||||
}}
|
||||
onSubmit={handleSignupSubmit}
|
||||
isSubmitting={loading || isSignupSubmitting}
|
||||
fieldErrors={signupFieldErrors}
|
||||
showName={false}
|
||||
showTerms={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
|
||||
interface SelfHostedLinkProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({
|
||||
onClick,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="navigation-link-container" style={{ marginTop: "1.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="navigation-link-button"
|
||||
>
|
||||
{t("setup.selfhosted.link", "or connect to a self hosted account")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
|
||||
import { UserInfo } from "@app/services/authService";
|
||||
import { SSOProviderConfig } from "@app/services/connectionModeService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
|
||||
interface SelfHostedLoginScreenProps {
|
||||
serverUrl: string;
|
||||
enabledOAuthProviders?: SSOProviderConfig[];
|
||||
loginMethod?: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
mfaCode: string;
|
||||
setMfaCode: (value: string) => void;
|
||||
requiresMfa: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
serverUrl,
|
||||
enabledOAuthProviders,
|
||||
loginMethod = "all",
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
mfaCode,
|
||||
setMfaCode,
|
||||
requiresMfa,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// Check if username/password authentication is allowed
|
||||
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
|
||||
|
||||
console.log("[SelfHostedLoginScreen] Props:", {
|
||||
serverUrl,
|
||||
enabledOAuthProviders,
|
||||
loginMethod,
|
||||
isUserPassAllowed,
|
||||
shouldShowOAuth: !!(
|
||||
enabledOAuthProviders && enabledOAuthProviders.length > 0
|
||||
),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(
|
||||
t("setup.login.error.emptyUsername", "Please enter your username"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(
|
||||
t("setup.login.error.emptyPassword", "Please enter your password"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresMfa && !mfaCode.trim()) {
|
||||
setValidationError(t("login.mfaRequired", "Two-factor code required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(username.trim(), password);
|
||||
};
|
||||
|
||||
const handleOAuthError = (errorMessage: string) => {
|
||||
setValidationError(errorMessage);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t("setup.selfhosted.title", "Sign in to Server")}
|
||||
subtitle={
|
||||
isUserPassAllowed
|
||||
? t("setup.selfhosted.subtitle", "Enter your server credentials")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
<Text size="sm" mb="md">
|
||||
{t("setup.login.connectingTo", "Connecting to:")}{" "}
|
||||
<Text span fw="500">
|
||||
{serverUrl}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{/* Show OAuth buttons if providers are available */}
|
||||
{enabledOAuthProviders && enabledOAuthProviders.length > 0 && (
|
||||
<>
|
||||
<DesktopOAuthButtons
|
||||
onOAuthSuccess={onOAuthSuccess}
|
||||
onError={handleOAuthError}
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
mode="selfHosted"
|
||||
providers={enabledOAuthProviders}
|
||||
/>
|
||||
|
||||
{/* Only show divider if username/password auth is also allowed */}
|
||||
{isUserPassAllowed && (
|
||||
<DividerWithText
|
||||
text={t("setup.login.orContinueWith", "Or continue with email")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Only show email/password form if username/password auth is allowed */}
|
||||
{isUserPassAllowed && (
|
||||
<EmailPasswordForm
|
||||
email={username}
|
||||
password={password}
|
||||
setEmail={(value) => {
|
||||
setUsername(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
mfaCode={mfaCode}
|
||||
setMfaCode={(value) => {
|
||||
setMfaCode(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
showMfaField={requiresMfa || Boolean(mfaCode)}
|
||||
requiresMfa={requiresMfa}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t("setup.login.submit", "Login")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import React, { useState } from "react";
|
||||
import { Stack, Button, TextInput, Alert, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ServerConfig,
|
||||
SSOProviderConfig,
|
||||
} from "@app/services/connectionModeService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface ServerSelectionProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const ServerSelection: React.FC<ServerSelectionProps> = ({
|
||||
onSelect,
|
||||
loading,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [securityDisabled, setSecurityDisabled] = useState(false);
|
||||
const serverUrl = localStorage.getItem("server_url") || "";
|
||||
|
||||
const submitServerUrl = async () => {
|
||||
// Normalize and validate URL
|
||||
let url = customUrl.trim().replace(/\/+$/, "") || serverUrl;
|
||||
|
||||
if (!url) {
|
||||
setTestError(
|
||||
t("setup.server.error.emptyUrl", "Please enter a server URL"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-add https:// if no protocol specified
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
console.log("[ServerSelection] No protocol specified, adding https://");
|
||||
url = `https://${url}`;
|
||||
setCustomUrl(url); // Update the input field
|
||||
}
|
||||
// Validate URL format
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
console.log("[ServerSelection] Valid URL:", {
|
||||
protocol: urlObj.protocol,
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
pathname: urlObj.pathname,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ServerSelection] Invalid URL format:", err);
|
||||
setTestError(
|
||||
t(
|
||||
"setup.server.error.invalidUrl",
|
||||
"Invalid URL format. Please enter a valid URL like https://your-server.com",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection before proceeding
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
setSecurityDisabled(false);
|
||||
|
||||
console.log(`[ServerSelection] Testing connection to: ${url}`);
|
||||
|
||||
try {
|
||||
const testResult = await connectionModeService.testConnection(url);
|
||||
|
||||
if (!testResult.success) {
|
||||
console.error("[ServerSelection] Connection test failed:", testResult);
|
||||
setTestError(
|
||||
testResult.error ||
|
||||
t("setup.server.error.unreachable", "Could not connect to server"),
|
||||
);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[ServerSelection] ✅ Connection test successful");
|
||||
|
||||
// Fetch OAuth providers and check if login is enabled
|
||||
const enabledProviders: SSOProviderConfig[] = [];
|
||||
let loginMethod = "all"; // Default to 'all' (allows both SSO and username/password)
|
||||
try {
|
||||
console.log("[ServerSelection] Fetching login configuration...");
|
||||
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
|
||||
|
||||
// Check if security is disabled (status 403, 401, or 404 - endpoint doesn't exist)
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[ServerSelection] Login config request failed with status ${response.status}`,
|
||||
);
|
||||
|
||||
if (
|
||||
response.status === 403 ||
|
||||
response.status === 401 ||
|
||||
response.status === 404
|
||||
) {
|
||||
console.log(
|
||||
"[ServerSelection] Security/SSO not configured on this server (or endpoint does not exist)",
|
||||
);
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
// Other error statuses - show generic error
|
||||
setTestError(
|
||||
t(
|
||||
"setup.server.error.configFetch",
|
||||
"Failed to fetch server configuration (status {{status}})",
|
||||
{
|
||||
status: response.status,
|
||||
},
|
||||
),
|
||||
);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("[ServerSelection] Login UI data:", data);
|
||||
|
||||
// Check if the response indicates security is disabled
|
||||
if (data.enableLogin === false || data.securityEnabled === false) {
|
||||
console.log(
|
||||
"[ServerSelection] Security is explicitly disabled in config",
|
||||
);
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract loginMethod from response
|
||||
loginMethod = data.loginMethod || "all";
|
||||
console.log("[ServerSelection] Login method:", loginMethod);
|
||||
|
||||
// Extract provider IDs from authorization URLs
|
||||
// Example: "/oauth2/authorization/google" → "google"
|
||||
const providerEntries = Object.entries(data.providerList || {});
|
||||
console.log(
|
||||
"[ServerSelection] providerList from API:",
|
||||
data.providerList,
|
||||
);
|
||||
providerEntries.forEach(([path, label]) => {
|
||||
const id = path.split("/").pop();
|
||||
console.log(
|
||||
"[ServerSelection] Processing provider path:",
|
||||
path,
|
||||
"→ id:",
|
||||
id,
|
||||
);
|
||||
if (!id) {
|
||||
console.warn(
|
||||
"[ServerSelection] Skipping provider with empty id:",
|
||||
path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
enabledProviders.push({
|
||||
id,
|
||||
path,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[ServerSelection] ✅ Detected OAuth providers:",
|
||||
enabledProviders,
|
||||
);
|
||||
console.log("[ServerSelection] Login method:", loginMethod);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[ServerSelection] ❌ Failed to fetch login configuration:",
|
||||
err,
|
||||
);
|
||||
|
||||
// Check if it's a security disabled error
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("403") || err.message.includes("401"))
|
||||
) {
|
||||
console.log(
|
||||
"[ServerSelection] Security is disabled (error-based detection)",
|
||||
);
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For any other error (network, CORS, invalid JSON, etc.), show error and don't proceed
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error";
|
||||
console.error(
|
||||
"[ServerSelection] Configuration fetch error details:",
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
setTestError(
|
||||
t(
|
||||
"setup.server.error.configFetchError",
|
||||
"Failed to fetch server configuration: {{error}}",
|
||||
{
|
||||
error: errorMessage,
|
||||
},
|
||||
),
|
||||
);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection successful — persist URL so it pre-fills on next sign-in
|
||||
localStorage.setItem("server_url", url);
|
||||
console.log(
|
||||
"[ServerSelection] ✅ Server selection complete, proceeding to login",
|
||||
);
|
||||
onSelect({
|
||||
url,
|
||||
enabledOAuthProviders:
|
||||
enabledProviders.length > 0 ? enabledProviders : undefined,
|
||||
loginMethod,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ServerSelection] ❌ Unexpected error during connection test:",
|
||||
error,
|
||||
);
|
||||
setTestError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("setup.server.error.testFailed", "Connection test failed"),
|
||||
);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitServerUrl();
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("setup.server.url.label", "Server URL")}
|
||||
placeholder="https://your-server.com"
|
||||
value={customUrl}
|
||||
onChange={(e) => {
|
||||
setCustomUrl(e.target.value);
|
||||
setTestError(null);
|
||||
setSecurityDisabled(false);
|
||||
}}
|
||||
disabled={loading || testing}
|
||||
error={testError}
|
||||
description={t(
|
||||
"setup.server.url.description",
|
||||
"Enter the full URL of your self-hosted Stirling PDF server",
|
||||
)}
|
||||
/>
|
||||
|
||||
{securityDisabled && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={
|
||||
<LocalIcon
|
||||
icon="warning-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
}
|
||||
title={t(
|
||||
"setup.server.error.securityDisabled.title",
|
||||
"Login Not Enabled",
|
||||
)}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"setup.server.error.securityDisabled.body",
|
||||
"This server does not have login enabled. To connect to this server, you must enable authentication:",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" component="div">
|
||||
<ol style={{ margin: 0, paddingLeft: "1.5rem" }}>
|
||||
<li>
|
||||
{t(
|
||||
"setup.server.error.securityDisabled.step1",
|
||||
"Set DOCKER_ENABLE_SECURITY=true in your environment",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"setup.server.error.securityDisabled.step2",
|
||||
"Or set security.enableLogin=true in settings.yml",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"setup.server.error.securityDisabled.step3",
|
||||
"Restart the server",
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{serverUrl && (
|
||||
<div className="navigation-link-container">
|
||||
<button
|
||||
type="button"
|
||||
className="navigation-link-button"
|
||||
disabled={testing || loading}
|
||||
onClick={() => {
|
||||
setCustomUrl(serverUrl);
|
||||
// Auto-submit the form after setting the URL
|
||||
setTimeout(() => {
|
||||
void submitServerUrl();
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
{t("setup.server.useLast", "Last used server: {{serverUrl}}", {
|
||||
serverUrl: serverUrl,
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={testing || loading}
|
||||
disabled={loading}
|
||||
mt="md"
|
||||
fullWidth
|
||||
color="#AF3434"
|
||||
>
|
||||
{testing
|
||||
? t("setup.server.testing", "Testing connection...")
|
||||
: t("common.continue", "Continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import { ServerSelection } from "@app/components/SetupWizard/ServerSelection";
|
||||
import { ServerConfig } from "@app/services/connectionModeService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
|
||||
interface ServerSelectionScreenProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({
|
||||
onSelect,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t("setup.server.title", "Connect to Server")}
|
||||
subtitle={t(
|
||||
"setup.server.subtitle",
|
||||
"Enter your self-hosted server URL",
|
||||
)}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<ServerSelection onSelect={onSelect} loading={loading} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/* Desktop-specific OAuth button styles for self-hosted server connections */
|
||||
/* These styles are isolated from the web SSO buttons to prevent conflicts */
|
||||
|
||||
.oauth-container-vertical-desktop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem; /* 14px */
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.oauth-button-vertical-desktop {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 1rem; /* 12px 16px */
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
background-color: var(--auth-card-bg-light-only);
|
||||
font-size: 1rem; /* 16px */
|
||||
font-weight: 500;
|
||||
color: var(--auth-text-primary-light-only);
|
||||
cursor: pointer;
|
||||
gap: 0.75rem; /* 12px */
|
||||
font-family: inherit;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.oauth-button-vertical-desktop:hover:not(:disabled) {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.oauth-button-vertical-desktop:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-button-vertical-desktop:focus-visible {
|
||||
outline: 2px solid var(--auth-border-focus-light-only);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Fix Mantine Button internal spans */
|
||||
.oauth-button-vertical-desktop .mantine-Button-inner {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.oauth-button-vertical-desktop .mantine-Button-label {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.oauth-button-left-desktop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.oauth-button-text-desktop {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
line-height: 1.2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.oauth-icon-wrapper-desktop {
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem; /* 20px */
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.oauth-icon-tiny-desktop {
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem; /* 20px */
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Text, Button, Alert, Loader, Center } from "@mantine/core";
|
||||
import { DesktopAuthLayout } from "@app/components/SetupWizard/DesktopAuthLayout";
|
||||
import { SaaSLoginScreen } from "@app/components/SetupWizard/SaaSLoginScreen";
|
||||
import { SaaSSignupScreen } from "@app/components/SetupWizard/SaaSSignupScreen";
|
||||
import { ServerSelectionScreen } from "@app/components/SetupWizard/ServerSelectionScreen";
|
||||
import { SelfHostedLoginScreen } from "@app/components/SetupWizard/SelfHostedLoginScreen";
|
||||
import {
|
||||
ServerConfig,
|
||||
SSOProviderConfig,
|
||||
connectionModeService,
|
||||
} from "@app/services/connectionModeService";
|
||||
import {
|
||||
AuthServiceError,
|
||||
authService,
|
||||
UserInfo,
|
||||
} from "@app/services/authService";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
|
||||
|
||||
enum SetupStep {
|
||||
SaaSLogin,
|
||||
SaaSSignup,
|
||||
ServerSelection,
|
||||
SelfHostedLogin,
|
||||
}
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: () => void;
|
||||
/** Omit the DesktopAuthLayout wrapper — use when rendering inside a modal */
|
||||
noLayout?: boolean;
|
||||
/** Called when the user dismisses the wizard (modal close button) */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({
|
||||
onComplete,
|
||||
noLayout = false,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({
|
||||
url: STIRLING_SAAS_URL,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState("");
|
||||
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
|
||||
const [lockConnectionMode, setLockConnectionMode] = useState(false);
|
||||
const [lockedServerUnreachable, setLockedServerUnreachable] = useState(false);
|
||||
const [lockedServerChecking, setLockedServerChecking] = useState(false);
|
||||
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError("No SaaS server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Only attempt password login if a password is provided
|
||||
// If password is empty, assume OAuth login already completed
|
||||
const isAlreadyAuthenticated = await authService.isAuthenticated();
|
||||
if (!isAlreadyAuthenticated && password) {
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
}
|
||||
|
||||
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error("SaaS login failed:", err);
|
||||
setError(err instanceof Error ? err.message : "SaaS login failed");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaaSLoginOAuth = async (_userInfo: UserInfo) => {
|
||||
if (!serverConfig) {
|
||||
setError("No SaaS server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// OAuth already completed by authService.loginWithOAuth
|
||||
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error("SaaS OAuth login completion failed:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to complete SaaS login",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalMode = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Save the server URL so it pre-fills on reconnect
|
||||
if (serverConfig?.url) {
|
||||
localStorage.setItem("server_url", serverConfig.url);
|
||||
}
|
||||
await connectionModeService.switchToLocal();
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error("Failed to continue in local mode:", err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelfHostedClick = () => {
|
||||
if (lockConnectionMode) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
};
|
||||
|
||||
const handleSwitchToSignup = () => {
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.SaaSSignup);
|
||||
};
|
||||
|
||||
const handleSwitchToLogin = () => {
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
};
|
||||
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
console.log("[SetupWizard] Server selected:", config);
|
||||
console.log("[SetupWizard] OAuth providers:", config.enabledOAuthProviders);
|
||||
console.log("[SetupWizard] Login method:", config.loginMethod);
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
setSelfHostedMfaCode("");
|
||||
setSelfHostedMfaRequired(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
};
|
||||
|
||||
const handleSelfHostedLogin = async (username: string, password: string) => {
|
||||
console.log("[SetupWizard] 🔐 Starting self-hosted login");
|
||||
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
|
||||
console.log(`[SetupWizard] Username: ${username}`);
|
||||
|
||||
if (!serverConfig) {
|
||||
console.error("[SetupWizard] ❌ No server configured");
|
||||
setError("No server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[SetupWizard] Step 1: Authenticating with server...");
|
||||
const trimmedMfa = selfHostedMfaCode.trim();
|
||||
const mfaCode = trimmedMfa ? trimmedMfa : undefined;
|
||||
await authService.login(serverConfig.url, username, password, mfaCode);
|
||||
console.log("[SetupWizard] ✅ Authentication successful");
|
||||
|
||||
setSelfHostedMfaRequired(false);
|
||||
setSelfHostedMfaCode("");
|
||||
|
||||
console.log("[SetupWizard] Step 2: Switching to self-hosted mode...");
|
||||
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||
console.log("[SetupWizard] ✅ Switched to self-hosted mode");
|
||||
|
||||
console.log("[SetupWizard] Step 3: Initializing external backend...");
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
console.log("[SetupWizard] ✅ External backend initialized");
|
||||
|
||||
console.log("[SetupWizard] ✅ Setup complete, calling onComplete()");
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error("[SetupWizard] ❌ Self-hosted login failed:", err);
|
||||
let errorMessage = "Self-hosted login failed";
|
||||
if (err instanceof AuthServiceError) {
|
||||
if (err.code === "mfa_required" || err.code === "invalid_mfa_code") {
|
||||
setSelfHostedMfaRequired(true);
|
||||
}
|
||||
errorMessage = err.message;
|
||||
} else if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === "string") {
|
||||
errorMessage = err;
|
||||
}
|
||||
if (
|
||||
errorMessage.toLowerCase().includes("mfa_required") ||
|
||||
errorMessage.toLowerCase().includes("invalid_mfa_code")
|
||||
) {
|
||||
setSelfHostedMfaRequired(true);
|
||||
}
|
||||
console.error("[SetupWizard] Error message:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelfHostedOAuthSuccess = async (_userInfo: UserInfo) => {
|
||||
console.log("[SetupWizard] 🔐 OAuth login successful, completing setup");
|
||||
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
|
||||
|
||||
if (!serverConfig) {
|
||||
console.error("[SetupWizard] ❌ No server configured");
|
||||
setError("No server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[SetupWizard] Step 1: OAuth already completed");
|
||||
console.log("[SetupWizard] Step 2: Switching to self-hosted mode...");
|
||||
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||
console.log("[SetupWizard] ✅ Switched to self-hosted mode");
|
||||
|
||||
console.log("[SetupWizard] Step 3: Initializing external backend...");
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
console.log("[SetupWizard] ✅ External backend initialized");
|
||||
|
||||
console.log("[SetupWizard] ✅ Setup complete, calling onComplete()");
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[SetupWizard] ❌ Self-hosted OAuth login completion failed:",
|
||||
err,
|
||||
);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to complete login";
|
||||
console.error("[SetupWizard] Error message:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribePromise = listen<string>("deep-link", async (event) => {
|
||||
const url = event.payload;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Supabase sends tokens in the URL hash
|
||||
const hash = parsed.hash.replace(/^#/, "");
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get("access_token");
|
||||
const type = params.get("type") || parsed.searchParams.get("type");
|
||||
// Self-hosted SSO deep links are normally handled by authService.loginWithSelfHostedOAuth.
|
||||
// Fallback here only if no in-flight auth listener exists (e.g. renderer reload mid-flow).
|
||||
if (type === "sso" || type === "sso-selfhosted") {
|
||||
if (authService.isSelfHostedDeepLinkFlowActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accessTokenFromHash = params.get("access_token");
|
||||
const accessTokenFromQuery = parsed.searchParams.get("access_token");
|
||||
const serverFromQuery = parsed.searchParams.get("server");
|
||||
const token = accessTokenFromHash || accessTokenFromQuery;
|
||||
const serverUrl =
|
||||
serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
|
||||
if (!token || !serverUrl) {
|
||||
console.error(
|
||||
"[SetupWizard] Deep link missing token or server for SSO completion",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.completeSelfHostedSession(serverUrl, token);
|
||||
await connectionModeService.switchToSelfHosted({ url: serverUrl });
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!type ||
|
||||
(type !== "signup" && type !== "recovery" && type !== "magiclink")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
console.error("[SetupWizard] Deep link missing access_token");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.completeSupabaseSession(
|
||||
accessToken,
|
||||
serverConfig?.url || STIRLING_SAAS_URL,
|
||||
);
|
||||
await connectionModeService.switchToSaaS(
|
||||
serverConfig?.url || STIRLING_SAAS_URL,
|
||||
);
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error("[SetupWizard] Failed to handle deep link", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to complete signup",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unsubscribePromise.then((unsub) => unsub());
|
||||
};
|
||||
}, [onComplete, serverConfig?.url]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (lockConnectionMode) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setSelfHostedMfaCode("");
|
||||
setSelfHostedMfaRequired(false);
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
} else if (activeStep === SetupStep.SaaSSignup) {
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLockedConfig = useCallback(async () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
if (!currentConfig.lock_connection_mode) return;
|
||||
const serverUrl = currentConfig.server_config?.url;
|
||||
if (!serverUrl) return;
|
||||
|
||||
setLockConnectionMode(true);
|
||||
setLockedServerUnreachable(false);
|
||||
setLockedServerChecking(true);
|
||||
|
||||
const savedUrl = serverUrl.replace(/\/+$/, "");
|
||||
let updatedConfig: ServerConfig = {
|
||||
...(currentConfig.server_config ?? { url: savedUrl }),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${savedUrl}/api/v1/proprietary/ui-data/login`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const enabledProviders: SSOProviderConfig[] = [];
|
||||
const providerEntries = Object.entries(data.providerList || {});
|
||||
|
||||
providerEntries.forEach(([path, label]) => {
|
||||
const id = path.split("/").pop();
|
||||
if (id) {
|
||||
enabledProviders.push({
|
||||
id,
|
||||
path,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
updatedConfig = {
|
||||
...updatedConfig,
|
||||
enabledOAuthProviders:
|
||||
enabledProviders.length > 0 ? enabledProviders : undefined,
|
||||
loginMethod: data.loginMethod || "all",
|
||||
};
|
||||
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
} else {
|
||||
// Server responded but with an error — still show login form
|
||||
updatedConfig = { ...updatedConfig, loginMethod: "all" };
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
}
|
||||
} catch (err) {
|
||||
// Network error — server is unreachable
|
||||
console.error("[SetupWizard] Server unreachable:", err);
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setLockedServerUnreachable(true);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLockedConfig();
|
||||
}, [loadLockedConfig]);
|
||||
|
||||
const wizardContent = (
|
||||
<>
|
||||
{/* Step Content */}
|
||||
{!lockConnectionMode && activeStep === SetupStep.SaaSLogin && (
|
||||
<SaaSLoginScreen
|
||||
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
|
||||
onLogin={handleSaaSLogin}
|
||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||
onSelfHostedClick={handleSelfHostedClick}
|
||||
onSwitchToSignup={handleSwitchToSignup}
|
||||
onSkipSignIn={handleLocalMode}
|
||||
onClose={onClose}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!lockConnectionMode && activeStep === SetupStep.SaaSSignup && (
|
||||
<SaaSSignupScreen
|
||||
loading={loading}
|
||||
error={error}
|
||||
onLogin={handleSaaSLogin}
|
||||
onSwitchToLogin={handleSwitchToLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!lockConnectionMode && activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelectionScreen
|
||||
onSelect={handleServerSelection}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lockConnectionMode && lockedServerChecking && (
|
||||
<Center py="xl">
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SelfHostedLogin &&
|
||||
lockedServerUnreachable &&
|
||||
!lockedServerChecking && (
|
||||
<Stack gap="md" style={{ padding: "0.5rem 0" }}>
|
||||
<Alert
|
||||
color="orange"
|
||||
title={t(
|
||||
"setup.selfhosted.unreachable.title",
|
||||
"Cannot connect to server",
|
||||
)}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"setup.selfhosted.unreachable.message",
|
||||
"Could not reach {{url}}. Check that the server is running and accessible.",
|
||||
{
|
||||
url: serverConfig?.url,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
onClick={() => void loadLockedConfig()}
|
||||
>
|
||||
{t("setup.selfhosted.unreachable.retry", "Retry")}
|
||||
</Button>
|
||||
{lockConnectionMode ? (
|
||||
<DisabledButtonWithTooltip
|
||||
tooltip={t(
|
||||
"setup.selfhosted.changeServerLocked",
|
||||
"Your organisation has restricted this app to a specific server",
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"setup.selfhosted.unreachable.changeServer",
|
||||
"Connect to a different server",
|
||||
)}
|
||||
</DisabledButtonWithTooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setLockedServerUnreachable(false);
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"setup.selfhosted.unreachable.changeServer",
|
||||
"Connect to a different server",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="white"
|
||||
fullWidth
|
||||
onClick={handleLocalMode}
|
||||
>
|
||||
{t(
|
||||
"setup.selfhosted.unreachable.continueOffline",
|
||||
"Use local tools instead",
|
||||
)}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SelfHostedLogin &&
|
||||
!lockedServerUnreachable &&
|
||||
!lockedServerChecking && (
|
||||
<>
|
||||
<SelfHostedLoginScreen
|
||||
serverUrl={serverConfig?.url || ""}
|
||||
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
||||
loginMethod={serverConfig?.loginMethod}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
||||
mfaCode={selfHostedMfaCode}
|
||||
setMfaCode={setSelfHostedMfaCode}
|
||||
requiresMfa={selfHostedMfaRequired}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<div
|
||||
className="navigation-link-container"
|
||||
style={{ marginTop: "1.5rem" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLocalMode}
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t("setup.selfhosted.switchToLocal", "Use local tools instead")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Button */}
|
||||
{!lockConnectionMode && activeStep > SetupStep.SaaSLogin && !loading && (
|
||||
<div
|
||||
className="navigation-link-container"
|
||||
style={{ marginTop: "1.5rem" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="navigation-link-button"
|
||||
>
|
||||
{t("common.back", "Back")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (noLayout) {
|
||||
return <div style={{ padding: "2rem" }}>{wizardContent}</div>;
|
||||
}
|
||||
|
||||
return <DesktopAuthLayout>{wizardContent}</DesktopAuthLayout>;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { SetupWizard } from "@app/components/SetupWizard";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
export function SignInModal() {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setLocked(detail?.locked === true);
|
||||
setOpened(true);
|
||||
};
|
||||
window.addEventListener(OPEN_SIGN_IN_EVENT, handler);
|
||||
return () => window.removeEventListener(OPEN_SIGN_IN_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
if (!opened) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
if (!locked) setOpened(false);
|
||||
}}
|
||||
size={520}
|
||||
centered
|
||||
withCloseButton={false}
|
||||
closeOnClickOutside={!locked}
|
||||
closeOnEscape={!locked}
|
||||
padding={0}
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_SIGN_IN_MODAL}
|
||||
>
|
||||
<SetupWizard
|
||||
noLayout
|
||||
onClose={() => setOpened(false)}
|
||||
onComplete={() => {
|
||||
setOpened(false);
|
||||
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||
// connectionModeService subscription when mode changes.
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
|
||||
|
||||
interface FileEditorStatusDotProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label = !file.localFilePath
|
||||
? t("fileNotSavedToDisk", "Not saved to disk")
|
||||
: file.isDirty
|
||||
? t("unsavedChanges", "Unsaved changes")
|
||||
: t("fileSavedToDisk", "Saved to disk");
|
||||
|
||||
const color = !file.localFilePath
|
||||
? "var(--mantine-color-red-6)"
|
||||
: file.isDirty
|
||||
? "var(--mantine-color-yellow-6)"
|
||||
: "var(--mantine-color-green-6)";
|
||||
|
||||
return (
|
||||
<div className={styles.thumbBadgesRight}>
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
className={styles.statusDot}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={label}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Desktop Override: Onboarding Configuration
|
||||
*
|
||||
* This version modifies the onboarding config for the desktop app:
|
||||
* - Sets isDesktopApp to true in the default runtime state
|
||||
* - This causes desktop-install step to be skipped
|
||||
*
|
||||
* All other step definitions and logic remain the same.
|
||||
*/
|
||||
|
||||
// Re-export everything from core
|
||||
export {
|
||||
ONBOARDING_STEPS,
|
||||
getStepById,
|
||||
getStepIndex,
|
||||
} from "@core/components/onboarding/orchestrator/onboardingConfig";
|
||||
|
||||
export type {
|
||||
OnboardingStepId,
|
||||
OnboardingStepType,
|
||||
OnboardingStep,
|
||||
OnboardingRuntimeState,
|
||||
OnboardingConditionContext,
|
||||
} from "@core/components/onboarding/orchestrator/onboardingConfig";
|
||||
|
||||
// Import and override the default runtime state
|
||||
import { DEFAULT_RUNTIME_STATE as CORE_DEFAULT_RUNTIME_STATE } from "@core/components/onboarding/orchestrator/onboardingConfig";
|
||||
import type { OnboardingRuntimeState } from "@core/components/onboarding/orchestrator/onboardingConfig";
|
||||
|
||||
/**
|
||||
* Desktop default runtime state
|
||||
* Sets isDesktopApp to true so desktop-install step is skipped
|
||||
*/
|
||||
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
...CORE_DEFAULT_RUNTIME_STATE,
|
||||
isDesktopApp: true,
|
||||
};
|
||||
+24
@@ -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,
|
||||
});
|
||||
}
|
||||
+90
@@ -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;
|
||||
};
|
||||
+46
@@ -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>
|
||||
);
|
||||
};
|
||||
+18
@@ -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;
|
||||
+709
@@ -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>
|
||||
);
|
||||
}
|
||||
+260
@@ -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>
|
||||
);
|
||||
}
|
||||
+257
@@ -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>
|
||||
);
|
||||
}
|
||||
+102
@@ -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>
|
||||
);
|
||||
}
|
||||
+79
@@ -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>
|
||||
);
|
||||
};
|
||||
+218
@@ -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>
|
||||
);
|
||||
};
|
||||
+127
@@ -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);
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Group, Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
connectionModeService,
|
||||
type ConnectionMode,
|
||||
} from "@app/services/connectionModeService";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
|
||||
/**
|
||||
* Desktop-only footer shown at the bottom of the tool list.
|
||||
* In local (offline) mode: prompts the user to sign in to unlock cloud tools.
|
||||
* In other modes: renders nothing.
|
||||
*/
|
||||
export function ToolPickerFooterExtensions() {
|
||||
const { t } = useTranslation();
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(
|
||||
(config) => {
|
||||
setConnectionMode(config.mode);
|
||||
},
|
||||
);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
if (connectionMode !== "local") return null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py={10}
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-default)",
|
||||
background: "var(--bg-toolbar)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" style={{ flex: 1, minWidth: 0 }}>
|
||||
{t("localMode.toolPicker.message", "Sign in to unlock all tools.")}
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))
|
||||
}
|
||||
>
|
||||
{t("localMode.toolPicker.signIn", "Sign In")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
import { usePrintCapability } from "@embedpdf/plugin-print/react";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady";
|
||||
import { printPdfNatively } from "@app/services/nativePrintService";
|
||||
import { DesktopOs, getDesktopOs } from "@app/services/platformService";
|
||||
import { PrintAPIBridgeProps } from "@core/components/viewer/PrintAPIBridge";
|
||||
|
||||
export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
|
||||
const { provides: print } = usePrintCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
useEffect(() => {
|
||||
if (documentReady) {
|
||||
registerBridge("print", {
|
||||
state: {},
|
||||
api: {
|
||||
print: () => {
|
||||
void (async () => {
|
||||
// macOS desktop uses a native print path because Tauri/WKWebView does not
|
||||
// reliably support iframe-based PDF printing yet:
|
||||
// https://github.com/tauri-apps/tauri/issues/13451#issuecomment-4045138142
|
||||
if ((await getDesktopOs()) === DesktopOs.Mac) {
|
||||
await printPdfNatively(file, url, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
print?.print?.();
|
||||
})().catch((error) => {
|
||||
console.error("[Desktop Print] Print failed", error);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge("print", null);
|
||||
};
|
||||
}, [documentReady, file, fileName, print, registerBridge, url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Billing Configuration for Desktop
|
||||
* Single source of truth for billing-related constants
|
||||
*/
|
||||
|
||||
export const BILLING_CONFIG = {
|
||||
// Credits included in Free plan (per month)
|
||||
FREE_CREDITS_PER_MONTH: 50,
|
||||
|
||||
// Credits included in Pro plan (per month)
|
||||
INCLUDED_CREDITS_PER_MONTH: 500,
|
||||
|
||||
// Overage pricing (per credit) - also fetched dynamically from Stripe
|
||||
OVERAGE_PRICE_PER_CREDIT: 0.05,
|
||||
|
||||
// Credit warning threshold (shows low-balance indicator)
|
||||
LOW_CREDIT_THRESHOLD: 10,
|
||||
|
||||
// Threshold at which plan pricing is preloaded (higher than LOW_CREDIT_THRESHOLD
|
||||
// so prices are ready before the warning UI appears)
|
||||
PLAN_PRICING_PRELOAD_THRESHOLD: 20,
|
||||
|
||||
// Stripe lookup keys
|
||||
PRO_PLAN_LOOKUP_KEY: "plan:pro",
|
||||
METER_LOOKUP_KEY: "meter:overage",
|
||||
|
||||
// Display formats
|
||||
CURRENCY_SYMBOLS: {
|
||||
gbp: "£",
|
||||
usd: "$",
|
||||
eur: "€",
|
||||
cny: "¥",
|
||||
inr: "₹",
|
||||
brl: "R$",
|
||||
idr: "Rp",
|
||||
jpy: "¥",
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get current billing configuration
|
||||
*/
|
||||
export function getBillingConfig() {
|
||||
return BILLING_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format overage price with currency symbol
|
||||
* @param currency Currency code (e.g., 'usd', 'gbp')
|
||||
* @param price Optional price override (defaults to BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT)
|
||||
*/
|
||||
export function getFormattedOveragePrice(
|
||||
currency: string = "usd",
|
||||
price?: number,
|
||||
): string {
|
||||
const symbol =
|
||||
BILLING_CONFIG.CURRENCY_SYMBOLS[
|
||||
currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS
|
||||
] || "$";
|
||||
const amount = price ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
||||
return `${symbol}${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency symbol from currency code
|
||||
*/
|
||||
export function getCurrencySymbol(currency: string): string {
|
||||
return (
|
||||
BILLING_CONFIG.CURRENCY_SYMBOLS[
|
||||
currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS
|
||||
] || currency.toUpperCase()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
/**
|
||||
* Default configuration used while the bundled backend starts up.
|
||||
*/
|
||||
export const DESKTOP_DEFAULT_APP_CONFIG: AppConfig = {
|
||||
enableLogin: false,
|
||||
premiumEnabled: false,
|
||||
runningProOrHigher: false,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Desktop plan features configuration
|
||||
* Single source of truth for plan features in desktop billing page
|
||||
*/
|
||||
|
||||
export interface PlanFeatureConfig {
|
||||
translationKey: string;
|
||||
defaultText: string;
|
||||
}
|
||||
|
||||
export const FREE_PLAN_FEATURES: PlanFeatureConfig[] = [
|
||||
{
|
||||
translationKey: "credits.modal.allInOneWorkspace",
|
||||
defaultText: "All-in-one PDF workspace (viewer, tools & agent)",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.fullyPrivateFiles",
|
||||
defaultText: "Fully private files",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.standardThroughput",
|
||||
defaultText: "Standard throughput",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.customSmartFolders",
|
||||
defaultText: "Custom Smart Folders",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.apiSandbox",
|
||||
defaultText: "API sandbox",
|
||||
},
|
||||
];
|
||||
|
||||
export const TEAM_PLAN_FEATURES: PlanFeatureConfig[] = [
|
||||
{
|
||||
translationKey: "credits.modal.unlimitedSeats",
|
||||
defaultText: "Unlimited seats",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.fasterThroughput",
|
||||
defaultText: "10x faster throughput",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.largeFileProcessing",
|
||||
defaultText: "Large file processing",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.premiumAiModels",
|
||||
defaultText: "Premium AI models",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.secureApiAccess",
|
||||
defaultText: "Secure API access",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.prioritySupport",
|
||||
defaultText: "Priority support",
|
||||
},
|
||||
];
|
||||
|
||||
export const ENTERPRISE_PLAN_FEATURES: PlanFeatureConfig[] = [
|
||||
{
|
||||
translationKey: "credits.modal.orgWideAccess",
|
||||
defaultText: "Org-wide access controls",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.privateDocCloud",
|
||||
defaultText: "Private Document Cloud",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.ragFineTuning",
|
||||
defaultText: "RAG + fine-tuning",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.unlimitedApiAccess",
|
||||
defaultText: "Unlimited API access",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.advancedMonitoring",
|
||||
defaultText: "Advanced monitoring",
|
||||
},
|
||||
{
|
||||
translationKey: "credits.modal.dedicatedSupportSlas",
|
||||
defaultText: "Dedicated support & SLAs",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
export const BACKEND_NOT_READY_CODE = "BACKEND_NOT_READY" as const;
|
||||
|
||||
export interface BackendNotReadyError extends Error {
|
||||
code: typeof BACKEND_NOT_READY_CODE;
|
||||
}
|
||||
|
||||
export function createBackendNotReadyError(): BackendNotReadyError {
|
||||
return Object.assign(
|
||||
new Error(i18n.t("backendHealth.starting", "Backend starting up...")),
|
||||
{
|
||||
code: BACKEND_NOT_READY_CODE,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function isBackendNotReadyError(
|
||||
error: unknown,
|
||||
): error is BackendNotReadyError {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === BACKEND_NOT_READY_CODE
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Connection-related constants for desktop app
|
||||
*/
|
||||
|
||||
// SaaS authentication server URL
|
||||
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL;
|
||||
|
||||
// Stirling SaaS backend API server (for team endpoints, etc.)
|
||||
export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env
|
||||
.VITE_SAAS_BACKEND_API_URL;
|
||||
|
||||
// Supabase publishable key — used for SaaS authentication
|
||||
export const SUPABASE_KEY: string = import.meta.env
|
||||
.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
// Desktop deep link callback for Supabase email confirmations
|
||||
export const DESKTOP_DEEP_LINK_CALLBACK = "stirlingpdf://auth/callback";
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Credit event constants for desktop credit system
|
||||
* Used for communication between credit monitoring, UI, and operations
|
||||
*/
|
||||
|
||||
export const CREDIT_EVENTS = {
|
||||
EXHAUSTED: "credits:exhausted",
|
||||
INSUFFICIENT: "credits:insufficient",
|
||||
REFRESH_NEEDED: "credits:refresh-needed",
|
||||
} as const;
|
||||
|
||||
export type CreditEventType =
|
||||
(typeof CREDIT_EVENTS)[keyof typeof CREDIT_EVENTS];
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* CustomEvent name for opening the desktop sign-in modal (SetupWizard).
|
||||
* Kept in a leaf module so apiClientSetup and others avoid importing SignInModal (heavy graph).
|
||||
*/
|
||||
export const OPEN_SIGN_IN_EVENT = "stirling:open-sign-in";
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { SaaSStripeCheckout } from "@app/components/shared/billing/SaaSStripeCheckout";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
|
||||
interface SaaSCheckoutContextType {
|
||||
opened: boolean;
|
||||
selectedPlan: string | null;
|
||||
openCheckout: (planId: string) => void;
|
||||
closeCheckout: () => void;
|
||||
}
|
||||
|
||||
const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useSaaSCheckout = () => {
|
||||
const context = useContext(SaaSCheckoutContext);
|
||||
if (!context) {
|
||||
throw new Error("useSaaSCheckout must be used within SaaSCheckoutProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface SaaSCheckoutProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
|
||||
// Access billing context for auto-refresh after checkout
|
||||
const { refreshBilling } = useSaaSBilling();
|
||||
|
||||
const openCheckout = (planId: string) => {
|
||||
setSelectedPlan(planId);
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
const closeCheckout = () => {
|
||||
setOpened(false);
|
||||
// Don't reset selectedPlan immediately to allow for cleanup
|
||||
setTimeout(() => setSelectedPlan(null), 300);
|
||||
};
|
||||
|
||||
// Internal success handler - automatically refreshes billing context
|
||||
const handleCheckoutSuccess = useCallback(async () => {
|
||||
// Wait for webhooks to process (2 seconds)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
try {
|
||||
await refreshBilling();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[SaaSCheckoutContext] Failed to refresh billing after checkout:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}, [refreshBilling]);
|
||||
|
||||
return (
|
||||
<SaaSCheckoutContext.Provider
|
||||
value={{
|
||||
opened,
|
||||
selectedPlan,
|
||||
openCheckout,
|
||||
closeCheckout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<SaaSStripeCheckout
|
||||
opened={opened}
|
||||
onClose={closeCheckout}
|
||||
planId={selectedPlan}
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
/>
|
||||
</SaaSCheckoutContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
/**
|
||||
* Desktop implementation of SaaS Team Context
|
||||
* Provides team management for users connected to SaaS backend
|
||||
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
|
||||
*/
|
||||
|
||||
interface Team {
|
||||
teamId: number;
|
||||
name: string;
|
||||
teamType: string;
|
||||
isPersonal: boolean;
|
||||
memberCount: number;
|
||||
seatCount: number;
|
||||
seatsUsed: number;
|
||||
maxSeats: number;
|
||||
isLeader: boolean;
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
interface TeamInvitation {
|
||||
invitationId: number;
|
||||
teamName: string;
|
||||
inviterEmail: string;
|
||||
inviteeEmail: string;
|
||||
invitationToken: string;
|
||||
status: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface SaaSTeamContextType {
|
||||
currentTeam: Team | null;
|
||||
teams: Team[];
|
||||
teamMembers: TeamMember[];
|
||||
teamInvitations: TeamInvitation[];
|
||||
receivedInvitations: TeamInvitation[];
|
||||
isTeamLeader: boolean;
|
||||
isPersonalTeam: boolean;
|
||||
loading: boolean;
|
||||
|
||||
inviteUser: (email: string) => Promise<void>;
|
||||
acceptInvitation: (token: string) => Promise<void>;
|
||||
rejectInvitation: (token: string) => Promise<void>;
|
||||
cancelInvitation: (invitationId: number) => Promise<void>;
|
||||
removeMember: (memberId: number) => Promise<void>;
|
||||
leaveTeam: () => Promise<void>;
|
||||
refreshTeams: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SaaSTeamContext = createContext<SaaSTeamContextType>({
|
||||
currentTeam: null,
|
||||
teams: [],
|
||||
teamMembers: [],
|
||||
teamInvitations: [],
|
||||
receivedInvitations: [],
|
||||
isTeamLeader: false,
|
||||
isPersonalTeam: true,
|
||||
loading: true,
|
||||
inviteUser: async () => {},
|
||||
acceptInvitation: async () => {},
|
||||
rejectInvitation: async () => {},
|
||||
cancelInvitation: async () => {},
|
||||
removeMember: async () => {},
|
||||
leaveTeam: async () => {},
|
||||
refreshTeams: async () => {},
|
||||
});
|
||||
|
||||
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTeam, setCurrentTeam] = useState<Team | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
|
||||
const [receivedInvitations, setReceivedInvitations] = useState<
|
||||
TeamInvitation[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaasMode, setIsSaasMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Check if in SaaS mode and authenticated
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === "saas");
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
|
||||
// Subscribe to connection mode changes
|
||||
const unsubscribe =
|
||||
connectionModeService.subscribeToModeChanges(checkAccess);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const fetchMyTeams = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log(
|
||||
"[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<Team[]>("/api/v1/team/my", {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
setTeams(response.data);
|
||||
|
||||
const activeTeam = response.data[0];
|
||||
console.log("[SaaSTeamContext] Current team set:", {
|
||||
teamId: activeTeam?.teamId,
|
||||
name: activeTeam?.name,
|
||||
isPersonal: activeTeam?.isPersonal,
|
||||
isLeader: activeTeam?.isLeader,
|
||||
});
|
||||
setCurrentTeam(activeTeam || null);
|
||||
return activeTeam || null;
|
||||
} catch (error) {
|
||||
console.error("[SaaSTeamContext] Failed to fetch teams:", error);
|
||||
return null;
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
const fetchTeamMembers = useCallback(
|
||||
async (teamId: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log(
|
||||
"[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamMember[]>(
|
||||
`/api/v1/team/${teamId}/members`,
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
setTeamMembers(response.data);
|
||||
} catch (error) {
|
||||
console.error("[SaaSTeamContext] Failed to fetch team members:", error);
|
||||
}
|
||||
},
|
||||
[isSaasMode, isAuthenticated],
|
||||
);
|
||||
|
||||
const fetchTeamInvitations = useCallback(
|
||||
async (teamId?: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated || !teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(
|
||||
`/api/v1/team/${teamId}/invitations`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
setTeamInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[SaaSTeamContext] Failed to fetch team invitations:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
[isSaasMode, isAuthenticated],
|
||||
);
|
||||
|
||||
const fetchReceivedInvitations = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SaaSTeamContext] Fetching received team invitations");
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(
|
||||
"/api/v1/team/invitations/pending",
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
console.log(
|
||||
"[SaaSTeamContext] Received invitations response:",
|
||||
response.data,
|
||||
);
|
||||
setReceivedInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[SaaSTeamContext] Failed to fetch received invitations:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaasMode && isAuthenticated) {
|
||||
fetchMyTeams();
|
||||
fetchReceivedInvitations();
|
||||
} else {
|
||||
// Clear state when not in SaaS mode or not authenticated
|
||||
setTeams([]);
|
||||
setCurrentTeam(null);
|
||||
setTeamMembers([]);
|
||||
setTeamInvitations([]);
|
||||
setReceivedInvitations([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentTeam &&
|
||||
!currentTeam.isPersonal &&
|
||||
isSaasMode &&
|
||||
isAuthenticated
|
||||
) {
|
||||
fetchTeamMembers(currentTeam.teamId);
|
||||
// Only fetch invitations if user is team leader
|
||||
if (currentTeam.isLeader) {
|
||||
fetchTeamInvitations(currentTeam.teamId);
|
||||
} else {
|
||||
setTeamInvitations([]);
|
||||
}
|
||||
} else {
|
||||
setTeamMembers([]);
|
||||
setTeamInvitations([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [
|
||||
currentTeam,
|
||||
isSaasMode,
|
||||
isAuthenticated,
|
||||
fetchTeamMembers,
|
||||
fetchTeamInvitations,
|
||||
]);
|
||||
|
||||
const inviteUser = async (email: string) => {
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post("/api/v1/team/invite", {
|
||||
teamId: currentTeam.teamId,
|
||||
email,
|
||||
});
|
||||
await fetchTeamInvitations(currentTeam.teamId);
|
||||
};
|
||||
|
||||
const acceptInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
|
||||
await fetchReceivedInvitations();
|
||||
await refreshTeams();
|
||||
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
|
||||
};
|
||||
|
||||
const rejectInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
|
||||
await fetchReceivedInvitations();
|
||||
};
|
||||
|
||||
const cancelInvitation = async (invitationId: number) => {
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
|
||||
if (currentTeam) {
|
||||
await fetchTeamInvitations(currentTeam.teamId);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (memberId: number) => {
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.delete(
|
||||
`/api/v1/team/${currentTeam.teamId}/members/${memberId}`,
|
||||
);
|
||||
await refreshTeams();
|
||||
await fetchTeamMembers(currentTeam.teamId);
|
||||
};
|
||||
|
||||
const leaveTeam = async () => {
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
|
||||
await refreshTeams();
|
||||
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
|
||||
};
|
||||
|
||||
const refreshTeams = useCallback(async () => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log(
|
||||
"[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newCurrentTeam = await fetchMyTeams();
|
||||
await fetchReceivedInvitations();
|
||||
if (newCurrentTeam && !newCurrentTeam.isPersonal) {
|
||||
await fetchTeamMembers(newCurrentTeam.teamId);
|
||||
// Only fetch invitations if user is team leader
|
||||
if (newCurrentTeam.isLeader) {
|
||||
await fetchTeamInvitations(newCurrentTeam.teamId);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isSaasMode,
|
||||
isAuthenticated,
|
||||
fetchMyTeams,
|
||||
fetchReceivedInvitations,
|
||||
fetchTeamMembers,
|
||||
fetchTeamInvitations,
|
||||
]);
|
||||
|
||||
const isTeamLeader = currentTeam?.isLeader ?? false;
|
||||
const isPersonalTeam = currentTeam?.isPersonal ?? true;
|
||||
|
||||
return (
|
||||
<SaaSTeamContext.Provider
|
||||
value={{
|
||||
currentTeam,
|
||||
teams,
|
||||
teamMembers,
|
||||
teamInvitations,
|
||||
receivedInvitations,
|
||||
isTeamLeader,
|
||||
isPersonalTeam,
|
||||
loading,
|
||||
inviteUser,
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
cancelInvitation,
|
||||
removeMember,
|
||||
leaveTeam,
|
||||
refreshTeams,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SaaSTeamContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSaaSTeam() {
|
||||
const context = useContext(SaaSTeamContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSaaSTeam must be used within a SaaSTeamProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SaaSTeamContext };
|
||||
export type { Team, TeamMember, TeamInvitation };
|
||||
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import {
|
||||
saasBillingService,
|
||||
BillingStatus,
|
||||
PlanPrice,
|
||||
} from "@app/services/saasBillingService";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import type { TierLevel } from "@app/types/billing";
|
||||
|
||||
// How long plan pricing is considered fresh. Lives at module level so both the
|
||||
// provider and usePlanPricing (the consumer hook) can reference it.
|
||||
const PLANS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/**
|
||||
* Desktop implementation of SaaS Billing Context
|
||||
* Provides billing and plan management for users connected to SaaS backend
|
||||
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
|
||||
*
|
||||
* Features:
|
||||
* - Centralized billing state management
|
||||
* - Automatic caching (5-minute TTL)
|
||||
* - Lazy loading (fetches on first access)
|
||||
* - Auto-refresh after checkout/portal
|
||||
* - No flickering (preserves data during refresh)
|
||||
*/
|
||||
|
||||
interface SaasBillingContextType {
|
||||
// Billing Status
|
||||
billingStatus: BillingStatus | null;
|
||||
tier: TierLevel;
|
||||
subscription: BillingStatus["subscription"];
|
||||
usage: BillingStatus["meterUsage"];
|
||||
isTrialing: boolean;
|
||||
trialDaysRemaining?: number;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
creditBalance: number; // Real-time remaining credits
|
||||
|
||||
// Available Plans
|
||||
plans: Map<string, PlanPrice>;
|
||||
plansLoading: boolean;
|
||||
plansError: string | null;
|
||||
plansLastFetchTime: number | null;
|
||||
|
||||
// Derived State
|
||||
isManagedTeamMember: boolean;
|
||||
|
||||
// State Flags
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastFetchTime: number | null;
|
||||
|
||||
// Actions
|
||||
refreshBilling: () => Promise<void>;
|
||||
refreshCredits: () => Promise<void>; // Alias for refreshBilling (for clarity)
|
||||
refreshPlans: () => Promise<void>;
|
||||
openBillingPortal: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SaasBillingContext = createContext<SaasBillingContextType>({
|
||||
billingStatus: null,
|
||||
tier: "free",
|
||||
subscription: null,
|
||||
usage: null,
|
||||
isTrialing: false,
|
||||
trialDaysRemaining: undefined,
|
||||
price: undefined,
|
||||
currency: undefined,
|
||||
creditBalance: 0,
|
||||
plans: new Map(),
|
||||
plansLoading: false,
|
||||
plansError: null,
|
||||
plansLastFetchTime: null,
|
||||
isManagedTeamMember: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
refreshBilling: async () => {},
|
||||
refreshCredits: async () => {},
|
||||
refreshPlans: async () => {},
|
||||
openBillingPortal: async () => {},
|
||||
});
|
||||
|
||||
export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
const [billingStatus, setBillingStatus] = useState<BillingStatus | null>(
|
||||
null,
|
||||
);
|
||||
const [plans, setPlans] = useState<Map<string, PlanPrice>>(new Map());
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
const [plansError, setPlansError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false); // Start false (lazy load)
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure).
|
||||
// lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context.
|
||||
const lastFetchTimeRef = useRef<number | null>(null);
|
||||
const [lastFetchTimeValue, setLastFetchTimeValue] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
// billingStatusRef mirrors billingStatus so fetchBillingData can read the current value
|
||||
// without needing billingStatus in its useCallback dep array.
|
||||
const billingStatusRef = useRef<BillingStatus | null>(null);
|
||||
// plansLastFetchTimeRef is the source of truth for timing; plansLastFetchTimeValue
|
||||
// is the state mirror exposed via context so consumers can react to it.
|
||||
const plansLastFetchTimeRef = useRef<number | null>(null);
|
||||
const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
// In-flight deduplication — prevents concurrent duplicate network requests.
|
||||
const plansFetchInProgressRef = useRef(false);
|
||||
const [isSaasMode, setIsSaasMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Access team context for derived state
|
||||
const {
|
||||
currentTeam,
|
||||
isPersonalTeam,
|
||||
isTeamLeader,
|
||||
loading: teamLoading,
|
||||
} = useSaaSTeam();
|
||||
|
||||
// Compute derived state: user is managed member if in non-personal team but not leader
|
||||
const isManagedTeamMember = currentTeam
|
||||
? !isPersonalTeam && !isTeamLeader
|
||||
: false;
|
||||
|
||||
// Check if in SaaS mode and authenticated (same pattern as SaaSTeamContext)
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === "saas");
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
|
||||
// Subscribe to connection mode changes
|
||||
const unsubscribe =
|
||||
connectionModeService.subscribeToModeChanges(checkAccess);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Fetch billing status with caching
|
||||
const fetchBillingData = useCallback(async () => {
|
||||
// Guard: Skip if not in SaaS mode or not authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: Wait for team context to load before determining managed status
|
||||
if (teamLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: Skip if managed team member (billing managed by leader)
|
||||
if (isManagedTeamMember) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache check: Skip if fresh data exists (<5 min old)
|
||||
const now = Date.now();
|
||||
if (
|
||||
billingStatusRef.current &&
|
||||
lastFetchTimeRef.current &&
|
||||
now - lastFetchTimeRef.current < 300000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set loading if no existing data (prevents flicker on refresh)
|
||||
if (!billingStatusRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await saasBillingService.getBillingStatus();
|
||||
billingStatusRef.current = status;
|
||||
setBillingStatus(status);
|
||||
lastFetchTimeRef.current = now;
|
||||
setLastFetchTimeValue(now);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("[SaasBillingContext] Failed to fetch billing:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch billing");
|
||||
// Don't clear billing status on error (preserve existing data)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, isManagedTeamMember, teamLoading]);
|
||||
|
||||
// Raw plan fetch — auth guard + in-flight deduplication only.
|
||||
// TTL cache logic lives in usePlanPricing so the consumer hook controls staleness policy.
|
||||
const fetchPlansData = useCallback(async () => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plansFetchInProgressRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
plansFetchInProgressRef.current = true;
|
||||
setPlansLoading(true);
|
||||
setPlansError(null);
|
||||
|
||||
try {
|
||||
const priceMap = await saasBillingService.getAvailablePlans("usd");
|
||||
setPlans(priceMap);
|
||||
const now = Date.now();
|
||||
plansLastFetchTimeRef.current = now;
|
||||
setPlansLastFetchTimeValue(now);
|
||||
} catch (err) {
|
||||
console.error("[SaasBillingContext] Failed to fetch plans:", err);
|
||||
setPlansError(
|
||||
err instanceof Error ? err.message : "Failed to fetch plans",
|
||||
);
|
||||
} finally {
|
||||
plansFetchInProgressRef.current = false;
|
||||
setPlansLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
// Clear data when leaving SaaS mode or logging out
|
||||
useEffect(() => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
// Clear state when not in SaaS mode or not authenticated
|
||||
billingStatusRef.current = null;
|
||||
setBillingStatus(null);
|
||||
setPlans(new Map());
|
||||
lastFetchTimeRef.current = null;
|
||||
setLastFetchTimeValue(null);
|
||||
plansLastFetchTimeRef.current = null;
|
||||
setPlansLastFetchTimeValue(null);
|
||||
plansFetchInProgressRef.current = false;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setPlansError(null);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
// Auto-fetch billing when team context finishes loading
|
||||
useEffect(() => {
|
||||
// Only fetch if: in SaaS mode, authenticated, team finished loading, and haven't fetched yet
|
||||
if (
|
||||
isSaasMode &&
|
||||
isAuthenticated &&
|
||||
!teamLoading &&
|
||||
!isManagedTeamMember &&
|
||||
billingStatusRef.current === null &&
|
||||
lastFetchTimeRef.current === null
|
||||
) {
|
||||
fetchBillingData();
|
||||
}
|
||||
}, [
|
||||
isSaasMode,
|
||||
isAuthenticated,
|
||||
teamLoading,
|
||||
isManagedTeamMember,
|
||||
fetchBillingData,
|
||||
]);
|
||||
|
||||
// Public refresh methods
|
||||
const refreshBilling = useCallback(async () => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force cache invalidation — write to ref synchronously so fetchBillingData
|
||||
// reads null immediately (not a stale closure value from the previous render).
|
||||
lastFetchTimeRef.current = null;
|
||||
setLastFetchTimeValue(null);
|
||||
await fetchBillingData();
|
||||
}, [isSaasMode, isAuthenticated, fetchBillingData]);
|
||||
|
||||
const refreshPlans = useCallback(async () => {
|
||||
await fetchPlansData();
|
||||
}, [fetchPlansData]);
|
||||
|
||||
const openBillingPortal = useCallback(async () => {
|
||||
const returnUrl = window.location.href;
|
||||
await saasBillingService.openBillingPortal(returnUrl);
|
||||
|
||||
// Auto-refresh after portal (delayed for webhook processing)
|
||||
setTimeout(() => {
|
||||
refreshBilling();
|
||||
}, 3000);
|
||||
}, [refreshBilling]);
|
||||
|
||||
// Listen for credit updates from API response headers (immediate, no fetch)
|
||||
useEffect(() => {
|
||||
const handleCreditsUpdated = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ creditsRemaining: number }>;
|
||||
const newBalance = customEvent.detail?.creditsRemaining;
|
||||
|
||||
if (typeof newBalance === "number" && billingStatus) {
|
||||
// Update credit balance in billing status without full refresh
|
||||
const updated = { ...billingStatus, creditBalance: newBalance };
|
||||
billingStatusRef.current = updated;
|
||||
setBillingStatus(updated);
|
||||
|
||||
// Dispatch exhausted event if credits hit 0
|
||||
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("credits:exhausted", {
|
||||
detail: {
|
||||
previousBalance: billingStatus.creditBalance ?? 0,
|
||||
currentBalance: newBalance,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("credits:updated", handleCreditsUpdated);
|
||||
return () => {
|
||||
window.removeEventListener("credits:updated", handleCreditsUpdated);
|
||||
};
|
||||
}, [billingStatus]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
billingStatus,
|
||||
tier: isManagedTeamMember ? "team" : (billingStatus?.tier ?? "free"),
|
||||
subscription: billingStatus?.subscription ?? null,
|
||||
usage: billingStatus?.meterUsage ?? null,
|
||||
isTrialing: billingStatus?.isTrialing ?? false,
|
||||
trialDaysRemaining: billingStatus?.trialDaysRemaining,
|
||||
price: plans.get("team")?.price,
|
||||
currency: plans.get("team")?.currency,
|
||||
creditBalance: billingStatus?.creditBalance ?? 0,
|
||||
plans,
|
||||
plansLoading,
|
||||
plansError,
|
||||
plansLastFetchTime: plansLastFetchTimeValue,
|
||||
isManagedTeamMember,
|
||||
loading: loading || teamLoading,
|
||||
error,
|
||||
lastFetchTime: lastFetchTimeValue,
|
||||
refreshBilling,
|
||||
refreshCredits: refreshBilling,
|
||||
refreshPlans,
|
||||
openBillingPortal,
|
||||
}),
|
||||
[
|
||||
billingStatus,
|
||||
isManagedTeamMember,
|
||||
plans,
|
||||
plansLoading,
|
||||
plansError,
|
||||
plansLastFetchTimeValue,
|
||||
loading,
|
||||
teamLoading,
|
||||
error,
|
||||
lastFetchTimeValue,
|
||||
refreshBilling,
|
||||
refreshPlans,
|
||||
openBillingPortal,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SaasBillingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SaasBillingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSaaSBilling() {
|
||||
const context = useContext(SaasBillingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSaaSBilling must be used within SaasBillingProvider");
|
||||
}
|
||||
|
||||
// Lazy fetch: Trigger fetch on first access (after team context loads)
|
||||
// Note: context.loading includes teamLoading, so this waits for team to load
|
||||
useEffect(() => {
|
||||
const needsFetch =
|
||||
context.billingStatus === null &&
|
||||
context.lastFetchTime === null &&
|
||||
!context.loading &&
|
||||
!context.isManagedTeamMember; // Managed members don't need billing data
|
||||
|
||||
if (needsFetch) {
|
||||
context.refreshBilling();
|
||||
}
|
||||
}, [
|
||||
context.billingStatus,
|
||||
context.lastFetchTime,
|
||||
context.loading,
|
||||
context.isManagedTeamMember,
|
||||
context.refreshBilling,
|
||||
]);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for components that display plan pricing data.
|
||||
* Fetches on first use; re-fetches only after PLANS_CACHE_TTL_MS (1 hour).
|
||||
* Safe to call from multiple components — in-flight deduplication is handled by fetchPlansData.
|
||||
*/
|
||||
export function usePlanPricing() {
|
||||
const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } =
|
||||
useContext(SaasBillingContext);
|
||||
|
||||
useEffect(() => {
|
||||
const isFresh =
|
||||
plansLastFetchTime !== null &&
|
||||
Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
|
||||
|
||||
if (!isFresh) {
|
||||
refreshPlans();
|
||||
}
|
||||
}, [plansLastFetchTime, refreshPlans]);
|
||||
|
||||
return { plans, plansLoading, plansError };
|
||||
}
|
||||
|
||||
export { SaasBillingContext };
|
||||
export type { SaasBillingContextType };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
interface AccountLogoutDeps {
|
||||
signOut: SignOutFn;
|
||||
redirectToLogin: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-specific logout: mirrors Connection Settings flow to avoid stale state.
|
||||
*/
|
||||
export function useAccountLogout() {
|
||||
return async ({
|
||||
signOut,
|
||||
redirectToLogin,
|
||||
}: AccountLogoutDeps): Promise<void> => {
|
||||
try {
|
||||
await signOut();
|
||||
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
// Save server URL before clearing so user can easily reconnect (self-hosted only)
|
||||
if (
|
||||
currentConfig.mode === "selfhosted" &&
|
||||
currentConfig.server_config?.url
|
||||
) {
|
||||
localStorage.setItem("server_url", currentConfig.server_config.url);
|
||||
}
|
||||
// Always switch to local after logout so the app remains usable
|
||||
await connectionModeService.switchToLocal();
|
||||
|
||||
window.history.replaceState({}, "", "/");
|
||||
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||
// connectionModeService subscription when mode changes to local.
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
redirectToLogin();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Desktop-specific OAuth callback handling for self-hosted SSO flows.
|
||||
*/
|
||||
export async function handleAuthCallbackSuccess(token: string): Promise<void> {
|
||||
// Notify desktop popup listeners (self-hosted SSO flow)
|
||||
const isDesktopPopup =
|
||||
typeof window !== "undefined" &&
|
||||
window.opener &&
|
||||
window.name === "stirling-desktop-sso";
|
||||
if (isDesktopPopup) {
|
||||
try {
|
||||
window.opener.postMessage({ type: "stirling-desktop-sso", token }, "*");
|
||||
} catch (postError) {
|
||||
console.error(
|
||||
"[AuthCallback] Failed to notify desktop window:",
|
||||
postError,
|
||||
);
|
||||
}
|
||||
|
||||
// Give the message a moment to flush before attempting to close
|
||||
setTimeout(() => {
|
||||
try {
|
||||
window.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// No-op beyond popup notification; deep link flow handles desktop completion.
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
/**
|
||||
* Desktop-specific auth cleanup hooks.
|
||||
*/
|
||||
export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
||||
try {
|
||||
await authService.localClearAuth();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[AuthCleanup] Failed to clear desktop auth data after sign out",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||
try {
|
||||
// Only clear if there's NO token in storage
|
||||
// If token exists, user just logged in and we should keep it
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("stirling_jwt")
|
||||
: null;
|
||||
console.log(
|
||||
"[AuthCleanup] Login init check - token exists:",
|
||||
!!token,
|
||||
"length:",
|
||||
token?.length || 0,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
console.log(
|
||||
"[AuthCleanup] No token found on login init, clearing stale auth data",
|
||||
);
|
||||
await authService.localClearAuth();
|
||||
} else {
|
||||
console.log(
|
||||
"[AuthCleanup] Token present on login init (length:",
|
||||
token.length,
|
||||
"), skipping cleanup (fresh login)",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[AuthCleanup] Failed to clear desktop auth data on login init",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function getCookieConsentOverrides(): Record<string, unknown> {
|
||||
return {
|
||||
cookie: {
|
||||
useLocalStorage: true, // Cookies don't reliably persist on desktop, but localStorage does
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
/**
|
||||
* Desktop-specific OAuth navigation: prefer popup/system browser, avoid hijacking main webview.
|
||||
*/
|
||||
export async function startOAuthNavigation(
|
||||
redirectUrl: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = await connectionModeService
|
||||
.getCurrentConfig()
|
||||
.catch(() => null);
|
||||
const serverUrl = currentConfig?.server_config?.url;
|
||||
if (!serverUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providerUrl = new URL(redirectUrl, serverUrl);
|
||||
const providerPath = `${providerUrl.pathname}${providerUrl.search}`;
|
||||
await authService.loginWithSelfHostedOAuth(providerPath, serverUrl);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[Desktop OAuthNavigation] Failed to start OAuth flow", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { authService } from "@app/services/authService";
|
||||
import type { PlatformSessionUser } from "@proprietary/extensions/platformSessionBridge";
|
||||
|
||||
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||
try {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
// Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
|
||||
// This skips redundant backend validation in springAuthClient since desktop authService
|
||||
// already manages the token lifecycle
|
||||
return mode === "saas" || mode === "selfhosted";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
|
||||
try {
|
||||
const userInfo = await authService.getUserInfo();
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
username: userInfo.username,
|
||||
email: userInfo.email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshPlatformSession(): Promise<boolean> {
|
||||
try {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === "saas") {
|
||||
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
|
||||
} else if (mode === "selfhosted") {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
return false;
|
||||
}
|
||||
return await authService.refreshToken(serverConfig.url);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save token to platform-specific secure storage (Tauri store + localStorage)
|
||||
* Called after token refresh to ensure token is synced across all storage locations
|
||||
*/
|
||||
export async function savePlatformToken(token: string): Promise<void> {
|
||||
try {
|
||||
await authService.saveToken(token);
|
||||
} catch (error) {
|
||||
console.error("[PlatformBridge] Failed to save token:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOpenedFile } from "@app/hooks/useOpenedFile";
|
||||
import { fileOpenService } from "@app/services/fileOpenService";
|
||||
import { useFileManagement } from "@app/contexts/file/fileHooks";
|
||||
import { createQuickKey } from "@app/types/fileContext";
|
||||
|
||||
/**
|
||||
* App initialization hook
|
||||
* Desktop version: Handles Tauri-specific file initialization
|
||||
* Requires FileContext - must be used inside FileContextProvider
|
||||
* - Handles files opened with the app (adds directly to FileContext)
|
||||
*/
|
||||
export function useAppInitialization(): void {
|
||||
// Get file management actions
|
||||
const { addFiles, updateStirlingFileStub } = useFileManagement();
|
||||
|
||||
// Handle files opened with app (Tauri mode)
|
||||
const {
|
||||
openedFilePaths,
|
||||
loading: openedFileLoading,
|
||||
consumeOpenedFilePaths,
|
||||
} = useOpenedFile();
|
||||
|
||||
// Load opened files and add directly to FileContext
|
||||
useEffect(() => {
|
||||
if (openedFilePaths.length === 0 || openedFileLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadOpenedFiles = async () => {
|
||||
const filePaths = consumeOpenedFilePaths();
|
||||
if (filePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loadedFiles = (
|
||||
await Promise.all(
|
||||
filePaths.map(async (filePath) => {
|
||||
try {
|
||||
const fileData =
|
||||
await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (!fileData) return null;
|
||||
|
||||
const file = new File(
|
||||
[fileData.arrayBuffer],
|
||||
fileData.fileName,
|
||||
{
|
||||
type: "application/pdf",
|
||||
},
|
||||
);
|
||||
|
||||
console.log("[Desktop] Loaded file:", fileData.fileName);
|
||||
|
||||
return {
|
||||
file,
|
||||
filePath,
|
||||
quickKey: createQuickKey(file),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Desktop] Failed to load file:",
|
||||
filePath,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { file: File; filePath: string; quickKey: string } =>
|
||||
Boolean(entry),
|
||||
);
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
const filesArray = loadedFiles.map((entry) => entry.file);
|
||||
const quickKeyToPath = new Map(
|
||||
loadedFiles.map((entry) => [entry.quickKey, entry.filePath]),
|
||||
);
|
||||
|
||||
const addedFiles = await addFiles(filesArray, { selectFiles: true });
|
||||
addedFiles.forEach((file) => {
|
||||
const localFilePath = quickKeyToPath.get(file.quickKey);
|
||||
if (localFilePath) {
|
||||
updateStirlingFileStub(file.fileId, { localFilePath });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Desktop] Failed to load opened files:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadOpenedFiles();
|
||||
}, [
|
||||
openedFilePaths,
|
||||
openedFileLoading,
|
||||
addFiles,
|
||||
updateStirlingFileStub,
|
||||
consumeOpenedFilePaths,
|
||||
]);
|
||||
}
|
||||
|
||||
export function useSetupCompletion(): (completed: boolean) => void {
|
||||
const [, setSetupComplete] = useState(false);
|
||||
|
||||
return (completed: boolean) => {
|
||||
setSetupComplete(completed);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { backendHealthMonitor } from "@app/services/backendHealthMonitor";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import type { BackendHealthState } from "@app/types/backendHealth";
|
||||
|
||||
/**
|
||||
* Hook to read backend health state for UI (Run button, BackendHealthIndicator).
|
||||
*
|
||||
* backendHealthMonitor tracks the local bundled backend.
|
||||
* selfHostedServerMonitor tracks the remote server in self-hosted mode.
|
||||
*
|
||||
* isOnline logic:
|
||||
* - SaaS mode: true when local backend is healthy
|
||||
* - Self-hosted mode (server online): true (remote is up)
|
||||
* - Self-hosted mode (server offline, local port known): true so the Run button
|
||||
* stays enabled — operationRouter routes supported tools to local
|
||||
* - Self-hosted mode (server offline, local port unknown): false
|
||||
*/
|
||||
export function useBackendHealth() {
|
||||
const [health, setHealth] = useState<BackendHealthState>(() =>
|
||||
backendHealthMonitor.getSnapshot(),
|
||||
);
|
||||
const [serverStatus, setServerStatus] = useState(
|
||||
() => selfHostedServerMonitor.getSnapshot().status,
|
||||
);
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(() =>
|
||||
tauriBackendService.getBackendUrl(),
|
||||
);
|
||||
const [connectionMode, setConnectionMode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges((config) =>
|
||||
setConnectionMode(config.mode),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return backendHealthMonitor.subscribe(setHealth);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return selfHostedServerMonitor.subscribe((state) =>
|
||||
setServerStatus(state.status),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return tauriBackendService.subscribeToStatus(() => {
|
||||
setLocalUrl(tauriBackendService.getBackendUrl());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
return backendHealthMonitor.checkNow();
|
||||
}, []);
|
||||
|
||||
const isOnline =
|
||||
connectionMode === "selfhosted"
|
||||
? serverStatus !== "offline" || !!localUrl
|
||||
: health.isOnline;
|
||||
|
||||
return {
|
||||
...health,
|
||||
isOnline,
|
||||
checkHealth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
import { useBackendHealth } from "@app/hooks/useBackendHealth";
|
||||
import { useEndpointConfig } from "@app/hooks/useEndpointConfig";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
|
||||
/**
|
||||
* Hook to initialize backend and monitor health
|
||||
* @param enabled - Whether to initialize the backend (default: true)
|
||||
*/
|
||||
export function useBackendInitializer(enabled = true) {
|
||||
const { status, checkHealth } = useBackendHealth();
|
||||
const { backendUrl } = useEndpointConfig();
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if disabled
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if backend already running
|
||||
if (tauriBackendService.isBackendRunning()) {
|
||||
void checkHealth();
|
||||
return;
|
||||
}
|
||||
|
||||
const initializeBackend = async () => {
|
||||
try {
|
||||
await tauriBackendService.startBackend(backendUrl);
|
||||
|
||||
// Begin health checks after a short delay
|
||||
setTimeout(() => {
|
||||
void checkHealth();
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("[BackendInitializer] Failed to start backend:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Only start backend if it's not already starting/healthy
|
||||
if (status !== "healthy" && status !== "starting") {
|
||||
void initializeBackend();
|
||||
}
|
||||
}, [enabled, status, backendUrl, checkHealth]);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { EXTENSION_TO_ENDPOINT } from "@app/constants/convertConstants";
|
||||
import { getEndpointName } from "@app/utils/convertUtils";
|
||||
|
||||
/**
|
||||
* Comprehensive conversion status data
|
||||
*/
|
||||
export interface ConversionStatus {
|
||||
availability: Record<string, boolean>; // Available on local OR SaaS?
|
||||
cloudStatus: Record<string, boolean>; // Will use cloud?
|
||||
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop hook to check conversion availability and cloud routing
|
||||
* Returns comprehensive data about each conversion
|
||||
* @returns Object with availability, cloudStatus, and localOnly maps
|
||||
*/
|
||||
export function useConversionCloudStatus(): ConversionStatus {
|
||||
const [status, setStatus] = useState<ConversionStatus>({
|
||||
availability: {},
|
||||
cloudStatus: {},
|
||||
localOnly: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkConversions = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Self-hosted offline path: server is down but local backend is available.
|
||||
// Check each conversion against the local backend only (no cloud routing).
|
||||
if (mode === "selfhosted") {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (status === "offline" && localUrl) {
|
||||
const pairs: [string, string, string][] = [];
|
||||
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
|
||||
for (const toExt of Object.keys(
|
||||
EXTENSION_TO_ENDPOINT[fromExt] || {},
|
||||
)) {
|
||||
const endpointName = getEndpointName(fromExt, toExt);
|
||||
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
|
||||
}
|
||||
}
|
||||
const availability: Record<string, boolean> = {};
|
||||
const cloudStatus: Record<string, boolean> = {};
|
||||
const localOnly: Record<string, boolean> = {};
|
||||
const results = await Promise.all(
|
||||
pairs.map(async ([fromExt, toExt, endpointName]) => {
|
||||
const key = `${fromExt}-${toExt}`;
|
||||
try {
|
||||
const supported =
|
||||
await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointName,
|
||||
localUrl,
|
||||
);
|
||||
return { key, supported };
|
||||
} catch {
|
||||
return { key, supported: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const { key, supported } of results) {
|
||||
availability[key] = supported;
|
||||
cloudStatus[key] = false;
|
||||
localOnly[key] = supported;
|
||||
}
|
||||
setStatus({ availability, cloudStatus, localOnly });
|
||||
return;
|
||||
}
|
||||
// Server online or local not ready: let normal endpoint checking handle it
|
||||
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't check until backend is healthy (SaaS startup guard)
|
||||
if (!tauriBackendService.isOnline) {
|
||||
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== "saas") {
|
||||
// Non-SaaS, non-self-hosted: local endpoint checking handles everything
|
||||
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
const availability: Record<string, boolean> = {};
|
||||
const cloudStatus: Record<string, boolean> = {};
|
||||
const localOnly: Record<string, boolean> = {};
|
||||
|
||||
// Collect all conversion pairs first, then check all in parallel
|
||||
const pairs: [string, string, string][] = [];
|
||||
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
|
||||
for (const toExt of Object.keys(EXTENSION_TO_ENDPOINT[fromExt] || {})) {
|
||||
const endpointName = getEndpointName(fromExt, toExt);
|
||||
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
pairs.map(async ([fromExt, toExt, endpointName]) => {
|
||||
const key = `${fromExt}-${toExt}`;
|
||||
try {
|
||||
// In SaaS mode, everything is available (locally or via cloud routing).
|
||||
// Only check local support to determine willUseCloud — the same approach
|
||||
// used by useMultipleEndpointsEnabled's SaaS enhancement.
|
||||
const availableLocally =
|
||||
await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointName,
|
||||
tauriBackendService.getBackendUrl(),
|
||||
);
|
||||
return {
|
||||
key,
|
||||
isAvailable: true,
|
||||
willUseCloud: !availableLocally,
|
||||
localOnly: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[useConversionCloudStatus] Endpoint check failed for ${key}:`,
|
||||
error,
|
||||
);
|
||||
// On error, assume available via cloud (safe default in SaaS mode)
|
||||
return {
|
||||
key,
|
||||
isAvailable: true,
|
||||
willUseCloud: true,
|
||||
localOnly: false,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const {
|
||||
key,
|
||||
isAvailable,
|
||||
willUseCloud: wuc,
|
||||
localOnly: lo,
|
||||
} of results) {
|
||||
availability[key] = isAvailable;
|
||||
cloudStatus[key] = wuc;
|
||||
localOnly[key] = lo;
|
||||
}
|
||||
|
||||
setStatus({ availability, cloudStatus, localOnly });
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkConversions();
|
||||
|
||||
// Re-check when SaaS local backend becomes healthy
|
||||
const unsubLocal = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === "healthy") {
|
||||
checkConversions();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-check when self-hosted server goes offline or comes back online.
|
||||
// By the time the server is confirmed offline, the local port is already
|
||||
// discovered (waitForPort completes in ~500ms vs the 8s server poll timeout).
|
||||
// selfHostedServerMonitor.subscribe immediately invokes the listener with the
|
||||
// current state, which would cause a duplicate check alongside the one above.
|
||||
// Skip the first invocation since checkConversions() was already called above.
|
||||
let skipFirst = true;
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
if (skipFirst) {
|
||||
skipFirst = false;
|
||||
return;
|
||||
}
|
||||
void checkConversions();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubLocal();
|
||||
unsubServer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSMode } from "@app/hooks/useSaaSMode";
|
||||
import { getToolCreditCost } from "@app/utils/creditCosts";
|
||||
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
/**
|
||||
* Desktop implementation of credit checking for cloud operations.
|
||||
* Hooks are called at render time; the returned checkCredits callback
|
||||
* closes over the billing state so it can be called safely inside
|
||||
* async operation handlers.
|
||||
*
|
||||
* Returns null when the operation is allowed, or an error message string
|
||||
* when it should be blocked.
|
||||
*/
|
||||
export function useCreditCheck(operationType?: string, endpoint?: string) {
|
||||
const billing = useSaaSBilling();
|
||||
const isSaaSMode = useSaaSMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const checkCredits = useCallback(
|
||||
async (runtimeEndpoint?: string): Promise<string | null> => {
|
||||
if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted
|
||||
if (!billing) return null;
|
||||
|
||||
// If the operation routes to the local backend, no credits are consumed — skip check.
|
||||
// runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint.
|
||||
const ep = runtimeEndpoint ?? endpoint;
|
||||
if (ep) {
|
||||
try {
|
||||
const willUseSaaS = await operationRouter.willRouteToSaaS(ep);
|
||||
if (!willUseSaaS) return null;
|
||||
} catch {
|
||||
// If routing check fails, fall through to credit check as a safe default.
|
||||
}
|
||||
}
|
||||
|
||||
const { creditBalance, loading } = billing;
|
||||
const requiredCredits = getToolCreditCost(operationType as ToolId);
|
||||
|
||||
if (!loading && creditBalance < requiredCredits) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, {
|
||||
detail: {
|
||||
operationType,
|
||||
requiredCredits,
|
||||
currentBalance: creditBalance,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return t(
|
||||
"credits.insufficient.brief",
|
||||
"Insufficient credits. You need {{required}} credits but have {{current}}.",
|
||||
{
|
||||
required: requiredCredits,
|
||||
current: creditBalance,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[billing, isSaaSMode, operationType, endpoint, t],
|
||||
);
|
||||
|
||||
return { checkCredits };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSMode } from "@app/hooks/useSaaSMode";
|
||||
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
|
||||
|
||||
/**
|
||||
* Desktop hook that monitors credit balance and dispatches events
|
||||
* when credits are exhausted or low.
|
||||
* Only active in SaaS mode — self-hosted users have no credit balance.
|
||||
*/
|
||||
export function useCreditEvents() {
|
||||
const isSaaSMode = useSaaSMode();
|
||||
const { creditBalance } = useSaaSBilling();
|
||||
const prevBalanceRef = useRef(creditBalance);
|
||||
|
||||
useEffect(() => {
|
||||
const prevBalance = prevBalanceRef.current;
|
||||
|
||||
// Dispatch exhausted event when credits reach 0 from positive balance.
|
||||
// Skip entirely in self-hosted mode — creditBalance defaults to 0 there.
|
||||
if (isSaaSMode && creditBalance <= 0 && prevBalance > 0) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
|
||||
detail: {
|
||||
previousBalance: prevBalance,
|
||||
currentBalance: creditBalance,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Update ref for next comparison
|
||||
prevBalanceRef.current = creditBalance;
|
||||
}, [isSaaSMode, creditBalance]);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultAppService } from "@app/services/defaultAppService";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
export const useDefaultApp = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isDefault, setIsDefault] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkDefaultStatus();
|
||||
}, []);
|
||||
|
||||
const checkDefaultStatus = async () => {
|
||||
try {
|
||||
const status = await defaultAppService.isDefaultPdfHandler();
|
||||
setIsDefault(status);
|
||||
} catch (error) {
|
||||
console.error("Failed to check default status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await defaultAppService.setAsDefaultPdfHandler();
|
||||
|
||||
if (result === "set_successfully") {
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("defaultApp.success.title", "Default App Set"),
|
||||
body: t(
|
||||
"defaultApp.success.message",
|
||||
"Stirling PDF is now your default PDF editor",
|
||||
),
|
||||
});
|
||||
setIsDefault(true);
|
||||
} else if (result === "opened_dialog") {
|
||||
alert({
|
||||
alertType: "neutral",
|
||||
title: t("defaultApp.settingsOpened.title", "Settings Opened"),
|
||||
body: t(
|
||||
"defaultApp.settingsOpened.message",
|
||||
"Please select Stirling PDF in the file association dialogue",
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set default:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("defaultApp.error.title", "Error"),
|
||||
body: t(
|
||||
"defaultApp.error.message",
|
||||
"Failed to set default PDF handler",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDefault,
|
||||
isLoading,
|
||||
checkDefaultStatus,
|
||||
handleSetDefault,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
/**
|
||||
* Shared hook for enabling metered (overage) billing via Supabase edge function.
|
||||
* Used by CreditExhaustedModal and InsufficientCreditsModal to avoid duplicate logic.
|
||||
*
|
||||
* @param refreshBilling - Callback to refresh billing state after success
|
||||
* @param onSuccess - Callback invoked after billing is enabled and refreshed
|
||||
* @param logPrefix - Label used in console messages for easier tracing
|
||||
*/
|
||||
export function useEnableMeteredBilling(
|
||||
refreshBilling: () => Promise<void>,
|
||||
onSuccess: () => void,
|
||||
logPrefix: string,
|
||||
): {
|
||||
enablingMetering: boolean;
|
||||
meteringError: string | null;
|
||||
handleEnableMetering: () => Promise<void>;
|
||||
} {
|
||||
const [enablingMetering, setEnablingMetering] = useState(false);
|
||||
const [meteringError, setMeteringError] = useState<string | null>(null);
|
||||
|
||||
const handleEnableMetering = async () => {
|
||||
console.debug(`[${logPrefix}] Enabling metered billing`);
|
||||
setEnablingMetering(true);
|
||||
setMeteringError(null);
|
||||
|
||||
try {
|
||||
const token = await authService.getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"create-meter-subscription",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to enable metered billing");
|
||||
}
|
||||
|
||||
if (!data?.success) {
|
||||
throw new Error(
|
||||
data?.error || data?.message || "Failed to enable metered billing",
|
||||
);
|
||||
}
|
||||
|
||||
console.debug(`[${logPrefix}] Metered billing enabled successfully`);
|
||||
|
||||
await refreshBilling();
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to enable metered billing";
|
||||
console.error(`[${logPrefix}] Failed to enable metered billing:`, err);
|
||||
setMeteringError(message);
|
||||
} finally {
|
||||
setEnablingMetering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { enablingMetering, meteringError, handleEnableMetering };
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { isBackendNotReadyError } from "@app/constants/backendErrors";
|
||||
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
interface EndpointConfig {
|
||||
backendUrl: string;
|
||||
}
|
||||
|
||||
const RETRY_DELAY_MS = 2500;
|
||||
|
||||
function isSelfHostedOffline(): boolean {
|
||||
return (
|
||||
selfHostedServerMonitor.getSnapshot().status === "offline" &&
|
||||
!!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (isAxiosError(err)) {
|
||||
const data = err.response?.data as { message?: string } | undefined;
|
||||
if (typeof data?.message === "string") {
|
||||
return data.message;
|
||||
}
|
||||
return err.message || "Unknown error occurred";
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return "Unknown error occurred";
|
||||
}
|
||||
|
||||
async function checkDependenciesReady(): Promise<boolean> {
|
||||
try {
|
||||
const response = await apiClient.get<AppConfig>(
|
||||
"/api/v1/config/app-config",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
return response.data?.dependenciesReady ?? false;
|
||||
} catch (error) {
|
||||
console.debug("[useEndpointConfig] Dependencies not ready yet:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-specific endpoint checker that hits the backend directly via axios.
|
||||
*/
|
||||
export function useEndpointEnabled(endpoint: string): {
|
||||
enabled: boolean | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
// DESKTOP: Start optimistically as enabled (most desktop users are in SaaS mode)
|
||||
// This prevents UI from being disabled while backend starts or checks are in progress
|
||||
const [enabled, setEnabled] = useState<boolean | null>(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearRetryTimeout = useCallback(() => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearRetryTimeout();
|
||||
};
|
||||
}, [clearRetryTimeout]);
|
||||
|
||||
const fetchEndpointStatus = useCallback(async () => {
|
||||
clearRetryTimeout();
|
||||
|
||||
if (!endpoint) {
|
||||
if (!isMountedRef.current) return;
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dependenciesReady = await checkDependenciesReady();
|
||||
if (!dependenciesReady) {
|
||||
return; // Health monitor will trigger retry when truly ready
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get<boolean>(
|
||||
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
|
||||
const locallyEnabled = response.data;
|
||||
|
||||
if (!locallyEnabled) {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
|
||||
// Even if not supported locally, they will route to SaaS backend
|
||||
if (mode === "saas") {
|
||||
console.debug(
|
||||
`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
|
||||
);
|
||||
setEnabled(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEnabled(locallyEnabled);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
if (isBackendStarting) {
|
||||
setError(t("backendHealth.starting", "Backend starting up..."));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchEndpointStatus();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume available even on check failure
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === "saas") {
|
||||
console.debug(
|
||||
`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
|
||||
);
|
||||
setEnabled(true); // Available via SaaS
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(message);
|
||||
setEnabled(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, clearRetryTimeout, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint) {
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// In self-hosted offline mode, enable optimistically when the local backend is ready.
|
||||
// ConvertSettings already filters unsupported endpoints from the dropdown,
|
||||
// so by the time the user has a valid endpoint selected it is supported locally.
|
||||
if (isSelfHostedOffline()) {
|
||||
setEnabled(true);
|
||||
setLoading(false);
|
||||
// Re-evaluate if the server comes back online
|
||||
return selfHostedServerMonitor.subscribe(() => {
|
||||
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (tauriBackendService.isOnline) {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === "healthy") {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [endpoint, fetchEndpointStatus]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchEndpointStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [endpointDetails, setEndpointDetails] = useState<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearRetryTimeout = useCallback(() => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearRetryTimeout();
|
||||
};
|
||||
}, [clearRetryTimeout]);
|
||||
|
||||
const fetchAllEndpointStatuses = useCallback(async () => {
|
||||
clearRetryTimeout();
|
||||
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Self-hosted offline: check each endpoint against the local backend directly.
|
||||
// checkDependenciesReady() would fail here since it hits the offline remote server.
|
||||
const { status: serverStatus } = selfHostedServerMonitor.getSnapshot();
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (serverStatus === "offline" && localUrl) {
|
||||
const results = await Promise.all(
|
||||
[...new Set(endpoints)].map(async (ep) => {
|
||||
try {
|
||||
const supported =
|
||||
await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
ep,
|
||||
localUrl,
|
||||
);
|
||||
return { ep, supported };
|
||||
} catch {
|
||||
return { ep, supported: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!isMountedRef.current) return;
|
||||
const statusMap: Record<string, boolean> = {};
|
||||
const details: Record<string, EndpointAvailabilityDetails> = {};
|
||||
for (const { ep, supported } of results) {
|
||||
statusMap[ep] = supported;
|
||||
details[ep] = {
|
||||
enabled: supported,
|
||||
reason: supported ? null : "NOT_SUPPORTED_LOCALLY",
|
||||
};
|
||||
}
|
||||
setEndpointDetails((prev) => ({ ...prev, ...details }));
|
||||
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dependenciesReady = await checkDependenciesReady();
|
||||
if (!dependenciesReady) {
|
||||
return; // Health monitor will trigger retry when truly ready
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Try new API first (no params — new servers return all endpoints).
|
||||
// Fall back to the old ?endpoints= form for servers that predate the
|
||||
// "large query reduction" change and still require the parameter.
|
||||
let response: Awaited<
|
||||
ReturnType<
|
||||
typeof apiClient.get<Record<string, EndpointAvailabilityDetails>>
|
||||
>
|
||||
>;
|
||||
try {
|
||||
response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(`/api/v1/config/endpoints-availability`, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
} catch (innerErr) {
|
||||
if (isAxiosError(innerErr) && innerErr.response?.status === 400) {
|
||||
// Old server — requires explicit endpoints query param
|
||||
console.debug(
|
||||
"[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format",
|
||||
);
|
||||
const endpointsParam = endpoints.join(",");
|
||||
response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
} else {
|
||||
throw innerErr;
|
||||
}
|
||||
}
|
||||
|
||||
const details = Object.entries(response.data).reduce(
|
||||
(acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? false,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, EndpointAvailabilityDetails>,
|
||||
);
|
||||
|
||||
const statusMap = Object.keys(details).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = details[key].enabled;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
|
||||
// They will route to SaaS backend
|
||||
if (mode === "saas") {
|
||||
const disabledEndpoints = Object.keys(details).filter(
|
||||
(key) => !details[key].enabled,
|
||||
);
|
||||
|
||||
for (const endpoint of disabledEndpoints) {
|
||||
console.debug(
|
||||
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
|
||||
);
|
||||
statusMap[endpoint] = true; // Mark as enabled via SaaS
|
||||
details[endpoint] = { enabled: true, reason: null };
|
||||
}
|
||||
}
|
||||
|
||||
setEndpointDetails((prev) => ({ ...prev, ...details }));
|
||||
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
if (isBackendStarting) {
|
||||
setError(t("backendHealth.starting", "Backend starting up..."));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchAllEndpointStatuses();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
setError(message);
|
||||
const fallbackStatus = endpoints.reduce(
|
||||
(acc, endpointName) => {
|
||||
const fallbackDetail: EndpointAvailabilityDetails = {
|
||||
enabled: false,
|
||||
reason: "UNKNOWN",
|
||||
};
|
||||
acc.status[endpointName] = false;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all endpoints as available
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === "saas") {
|
||||
for (const endpoint of endpoints) {
|
||||
console.debug(
|
||||
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
|
||||
);
|
||||
fallbackStatus.status[endpoint] = true;
|
||||
fallbackStatus.details[endpoint] = { enabled: true, reason: null };
|
||||
}
|
||||
}
|
||||
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...fallbackStatus.details }));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoints, clearRetryTimeout, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelfHostedOffline()) {
|
||||
fetchAllEndpointStatuses();
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
});
|
||||
return unsubServer;
|
||||
}
|
||||
|
||||
if (tauriBackendService.isOnline) {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === "healthy") {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [endpoints, fetchAllEndpointStatuses]);
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchAllEndpointStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
// Default backend URL from environment variables
|
||||
const DEFAULT_BACKEND_URL =
|
||||
import.meta.env.VITE_DESKTOP_BACKEND_URL || import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
/**
|
||||
* Desktop override exposing the backend URL based on connection mode.
|
||||
* - SaaS mode: Uses local bundled backend (from env vars)
|
||||
* - Self-hosted mode: Uses configured server URL from connection config
|
||||
*/
|
||||
export function useEndpointConfig(): EndpointConfig {
|
||||
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
|
||||
|
||||
useEffect(() => {
|
||||
connectionModeService
|
||||
.getCurrentConfig()
|
||||
.then((config) => {
|
||||
if (config.mode === "selfhosted" && config.server_config?.url) {
|
||||
setBackendUrl(config.server_config.url);
|
||||
} else {
|
||||
// SaaS mode - use default from env vars (local backend)
|
||||
setBackendUrl(DEFAULT_BACKEND_URL);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get connection config:", err);
|
||||
// Keep current URL on error
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { backendUrl };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useExitWarning() {
|
||||
const { t } = useTranslation();
|
||||
const { selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const selectorsRef = useRef(selectors);
|
||||
const isClosingRef = useRef(false);
|
||||
|
||||
selectorsRef.current = selectors;
|
||||
|
||||
useEffect(() => {
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
const handleCloseRequested = async (event: {
|
||||
preventDefault: () => void;
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isClosingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allStubs = selectorsRef.current.getStirlingFileStubs();
|
||||
const dirtyStubs = allStubs.filter((stub) => stub.isDirty);
|
||||
|
||||
if (dirtyStubs.length > 0) {
|
||||
const fileList = dirtyStubs.map((f) => `• ${f.name}`).join("\n");
|
||||
const saveLabel = t("confirmCloseSave", "Save and close");
|
||||
const discardLabel = t(
|
||||
"confirmCloseDiscard",
|
||||
"Discard changes and close",
|
||||
);
|
||||
const cancelLabel = t("confirmCloseCancel", "Cancel");
|
||||
|
||||
const choice = await message(
|
||||
t(
|
||||
"confirmCloseUnsavedList",
|
||||
"You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}",
|
||||
{
|
||||
count: dirtyStubs.length,
|
||||
plural: dirtyStubs.length > 1 ? "s" : "",
|
||||
fileList,
|
||||
},
|
||||
),
|
||||
{
|
||||
title: t("confirmCloseUnsaved", "This file has unsaved changes."),
|
||||
kind: "warning",
|
||||
buttons: {
|
||||
yes: saveLabel,
|
||||
no: discardLabel,
|
||||
cancel: cancelLabel,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (choice === cancelLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === saveLabel) {
|
||||
const { failedCount, cancelled } = await saveDirtyFiles(dirtyStubs);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
await message(
|
||||
t(
|
||||
"confirmCloseSaveFailed",
|
||||
"Saved with errors. {{count}} file{{plural}} could not be saved.",
|
||||
{
|
||||
count: failedCount,
|
||||
plural: failedCount > 1 ? "s" : "",
|
||||
},
|
||||
),
|
||||
{
|
||||
title: t("confirmCloseSaveFailedTitle", "Save Failed"),
|
||||
kind: "error",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (choice !== discardLabel) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isClosingRef.current = true;
|
||||
try {
|
||||
await appWindow.destroy();
|
||||
} catch (error) {
|
||||
console.error("[exit-warning] destroy failed", error);
|
||||
isClosingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unlisten = appWindow.onCloseRequested(handleCloseRequested);
|
||||
return () => {
|
||||
unlisten.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, [fileActions, t]);
|
||||
|
||||
const saveDirtyFiles = async (dirtyStubs: StirlingFileStub[]) => {
|
||||
const filesById = new Map(
|
||||
selectorsRef.current.getFiles().map((file) => [file.fileId, file]),
|
||||
);
|
||||
let failedCount = 0;
|
||||
let cancelled = false;
|
||||
|
||||
for (const stub of dirtyStubs) {
|
||||
const file = filesById.get(stub.id);
|
||||
if (!file || !stub.localFilePath) {
|
||||
if (!file) {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: stub.localFilePath,
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
failedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { failedCount, cancelled };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||
import SaveOutlinedIcon from "@mui/icons-material/SaveOutlined";
|
||||
|
||||
/**
|
||||
* File action icons for desktop builds.
|
||||
* Overrides the core implementation with desktop-appropriate icons.
|
||||
* The presence of `saveAsIconName` signals the WorkbenchBar to show the Save As button.
|
||||
*/
|
||||
export function useFileActionIcons() {
|
||||
return {
|
||||
upload: FolderOpenOutlinedIcon,
|
||||
download: SaveOutlinedIcon,
|
||||
uploadIconName: "folder-rounded" as const,
|
||||
downloadIconName: "save-rounded" as const,
|
||||
// Returning this icon name causes WorkbenchBar to render the Save As button.
|
||||
// On desktop, downloadFile() without a localPath shows a native save dialog.
|
||||
saveAsIconName: "save-as-rounded" as string | undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* File action terminology for desktop builds
|
||||
* Overrides core implementation with desktop-appropriate terminology
|
||||
*/
|
||||
export function useFileActionTerminology() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
uploadFiles: t("fileUpload.openFiles", "Open Files"),
|
||||
uploadFile: t("fileUpload.openFile", "Open File"),
|
||||
upload: t("fileUpload.open", "Open"),
|
||||
dropFilesHere: t(
|
||||
"fileUpload.dropFilesHereOpen",
|
||||
"Drop files here or click the open button",
|
||||
),
|
||||
addFiles: t("fileUpload.openFiles", "Open Files"),
|
||||
mobileUpload: t("landing.mobileUpload", "Upload from Mobile"),
|
||||
uploadFromComputer: t("landing.openFromComputer", "Open from computer"),
|
||||
download: t("save", "Save"),
|
||||
downloadAll: t("workbenchBar.saveAll", "Save All"),
|
||||
downloadSelected: t("fileManager.saveSelected", "Save Selected"),
|
||||
downloadUnavailable: t("saveUnavailable", "Save unavailable for this item"),
|
||||
noFilesInStorage: t(
|
||||
"fileUpload.noFilesInStorageOpen",
|
||||
"No files available in storage. Open some files first.",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
/**
|
||||
* First launch check hook
|
||||
* Checks if this is the first time the app is being launched
|
||||
* Does not require FileContext - can be used early in the provider hierarchy
|
||||
*/
|
||||
export function useFirstLaunchCheck(): {
|
||||
isFirstLaunch: boolean;
|
||||
setupComplete: boolean;
|
||||
} {
|
||||
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
|
||||
const [setupComplete, setSetupComplete] = useState(false);
|
||||
const setupCheckCompleteRef = useRef(false);
|
||||
|
||||
// Check if this is first launch
|
||||
useEffect(() => {
|
||||
const checkFirstLaunch = async () => {
|
||||
try {
|
||||
const firstLaunch = await connectionModeService.isFirstLaunch();
|
||||
setIsFirstLaunch(firstLaunch);
|
||||
|
||||
if (!firstLaunch) {
|
||||
// Not first launch - initialize auth state
|
||||
await authService.initializeAuthState();
|
||||
setSetupComplete(true);
|
||||
}
|
||||
|
||||
setupCheckCompleteRef.current = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to check first launch:", error);
|
||||
// On error, assume not first launch and proceed
|
||||
setIsFirstLaunch(false);
|
||||
setSetupComplete(true);
|
||||
setupCheckCompleteRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (!setupCheckCompleteRef.current) {
|
||||
checkFirstLaunch();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isFirstLaunch, setupComplete };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import type { FrontendVersionInfo } from "@core/hooks/useFrontendVersionInfo";
|
||||
|
||||
export function useFrontendVersionInfo(
|
||||
backendVersion: string | undefined,
|
||||
): FrontendVersionInfo {
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [mismatchVersion, setMismatchVersion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const version = await getVersion();
|
||||
if (!cancelled) {
|
||||
setAppVersion(version);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useFrontendVersionInfo] Failed to fetch frontend version:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
fetchVersion();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appVersion || !backendVersion) {
|
||||
setMismatchVersion(false);
|
||||
return;
|
||||
}
|
||||
if (appVersion !== backendVersion) {
|
||||
console.warn(
|
||||
"[useFrontendVersionInfo] Mismatch between frontend version and AppConfig version:",
|
||||
{
|
||||
backendVersion,
|
||||
frontendVersion: appVersion,
|
||||
},
|
||||
);
|
||||
setMismatchVersion(true);
|
||||
} else {
|
||||
setMismatchVersion(false);
|
||||
}
|
||||
}, [appVersion, backendVersion]);
|
||||
|
||||
return { appVersion, mismatchVersion };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import type { GroupEnabledResult } from "@app/types/groupEnabled";
|
||||
|
||||
const OFFLINE_REASON_FALLBACK =
|
||||
"Requires your Stirling-PDF server (currently offline)";
|
||||
|
||||
/**
|
||||
* Desktop override: skips the network request entirely when the self-hosted
|
||||
* server is confirmed offline, returning a reason string matching the tool panel.
|
||||
*/
|
||||
export function useGroupEnabled(group: string): GroupEnabledResult {
|
||||
const { t } = useTranslation();
|
||||
// Initialise synchronously so the first render already reflects offline state —
|
||||
// avoids a flash where the option appears enabled before the effect runs.
|
||||
// Use OFFLINE_REASON_FALLBACK directly so unavailableReason is non-null from
|
||||
// the very first render when offline (t() is not available in useState initialiser).
|
||||
const [result, setResult] = useState<GroupEnabledResult>(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === "offline") {
|
||||
return { enabled: false, unavailableReason: OFFLINE_REASON_FALLBACK };
|
||||
}
|
||||
return { enabled: null, unavailableReason: null };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === "offline") {
|
||||
setResult({
|
||||
enabled: false,
|
||||
unavailableReason: t(
|
||||
"toolPanel.fullscreen.selfHostedOffline",
|
||||
OFFLINE_REASON_FALLBACK,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
apiClient
|
||||
.get<boolean>(
|
||||
`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`,
|
||||
)
|
||||
.then((res) => setResult({ enabled: res.data, unavailableReason: null }))
|
||||
.catch(() => setResult({ enabled: false, unavailableReason: null }));
|
||||
}, [group, t]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
|
||||
|
||||
/**
|
||||
* Desktop override: shared (group) signing requires self-hosted mode AND
|
||||
* an authenticated session. Returns false in SaaS/local mode or when logged out.
|
||||
*/
|
||||
export function useGroupSigningEnabled(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
const { isSelfHosted, isAuthenticated } = useSelfHostedAuth();
|
||||
return (
|
||||
isSelfHosted &&
|
||||
isAuthenticated &&
|
||||
config?.storageGroupSigningEnabled === true
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { fileOpenService } from "@app/services/fileOpenService";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export function useOpenedFile() {
|
||||
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const openedFilePathsRef = useRef<string[]>([]);
|
||||
|
||||
const clearOpenedFilePaths = useCallback(() => {
|
||||
openedFilePathsRef.current = [];
|
||||
setOpenedFilePaths([]);
|
||||
}, []);
|
||||
|
||||
const consumeOpenedFilePaths = useCallback(() => {
|
||||
const current = openedFilePathsRef.current;
|
||||
openedFilePathsRef.current = [];
|
||||
setOpenedFilePaths([]);
|
||||
return current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Function to read and process files from storage
|
||||
const readFilesFromStorage = async () => {
|
||||
console.log("🔍 Reading files from storage...");
|
||||
try {
|
||||
const filePaths = await fileOpenService.getOpenedFiles();
|
||||
console.log("🔍 fileOpenService.getOpenedFiles() returned:", filePaths);
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
console.log(
|
||||
`✅ Found ${filePaths.length} file(s) in storage:`,
|
||||
filePaths,
|
||||
);
|
||||
openedFilePathsRef.current = filePaths;
|
||||
setOpenedFilePaths(filePaths);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to read files from storage:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Read files on mount
|
||||
readFilesFromStorage();
|
||||
|
||||
// Listen for files-changed events (when new files are added to storage)
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen("files-changed", async () => {
|
||||
console.log("📂 files-changed event received, re-reading storage...");
|
||||
await readFilesFromStorage();
|
||||
}).then((unlistenFn) => {
|
||||
unlisten = unlistenFn;
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openedFilePaths,
|
||||
loading,
|
||||
clearOpenedFilePaths,
|
||||
consumeOpenedFilePaths,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
/**
|
||||
* Returns whether the app is currently in SaaS connection mode.
|
||||
* Starts optimistically true (most common for desktop) to avoid tools
|
||||
* being incorrectly marked unavailable during initial load.
|
||||
*/
|
||||
export function useSaaSMode(): boolean {
|
||||
const [isSaaSMode, setIsSaaSMode] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService
|
||||
.getCurrentMode()
|
||||
.then((mode) => setIsSaaSMode(mode === "saas"));
|
||||
return connectionModeService.subscribeToModeChanges((cfg) =>
|
||||
setIsSaaSMode(cfg.mode === "saas"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return isSaaSMode;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useSaaSBilling,
|
||||
usePlanPricing,
|
||||
} from "@app/contexts/SaasBillingContext";
|
||||
import {
|
||||
FREE_PLAN_FEATURES,
|
||||
TEAM_PLAN_FEATURES,
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
} from "@app/config/planFeatures";
|
||||
import type { TierLevel } from "@app/types/billing";
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
export interface PlanTier {
|
||||
id: TierLevel;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
popular?: boolean;
|
||||
features: PlanFeature[];
|
||||
highlights: string[];
|
||||
isContactOnly?: boolean;
|
||||
overagePrice?: number;
|
||||
}
|
||||
|
||||
export const useSaaSPlans = () => {
|
||||
const { t } = useTranslation();
|
||||
const { refreshPlans } = useSaaSBilling();
|
||||
const { plans, plansLoading, plansError } = usePlanPricing();
|
||||
|
||||
const computedPlans = useMemo<PlanTier[]>(() => {
|
||||
const teamPlan = plans.get("team");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "free",
|
||||
name: t("plan.free.name", "Free"),
|
||||
price: 0,
|
||||
currency: "$",
|
||||
period: t("plan.period.month", "/month"),
|
||||
highlights: FREE_PLAN_FEATURES.map((f) =>
|
||||
t(f.translationKey, f.defaultText),
|
||||
),
|
||||
features: [
|
||||
{
|
||||
name: t("plan.feature.pdfTools", "Basic PDF Tools"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.fileSize", "File Size Limit"),
|
||||
included: false,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.automation", "Automate tool workflows"),
|
||||
included: false,
|
||||
},
|
||||
{ name: t("plan.feature.api", "API Access"), included: false },
|
||||
{
|
||||
name: t("plan.feature.priority", "Priority Support"),
|
||||
included: false,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: t("plan.team.name", "Team"),
|
||||
price: teamPlan?.price || 10,
|
||||
currency: teamPlan?.currency || "$",
|
||||
period: t("plan.period.month", "/month"),
|
||||
popular: true,
|
||||
overagePrice: teamPlan?.overagePrice || 0.05,
|
||||
highlights: TEAM_PLAN_FEATURES.map((f) =>
|
||||
t(f.translationKey, f.defaultText),
|
||||
),
|
||||
features: [
|
||||
{
|
||||
name: t("plan.feature.pdfTools", "Basic PDF Tools"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.fileSize", "File Size Limit"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.automation", "Automate tool workflows"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.api", "Monthly API Credits"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.priority", "Priority Support"),
|
||||
included: false,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: t("plan.enterprise.name", "Enterprise"),
|
||||
price: 0,
|
||||
currency: "$",
|
||||
period: "",
|
||||
isContactOnly: true,
|
||||
highlights: ENTERPRISE_PLAN_FEATURES.map((f) =>
|
||||
t(f.translationKey, f.defaultText),
|
||||
),
|
||||
features: [
|
||||
{
|
||||
name: t("plan.feature.pdfTools", "Basic PDF Tools"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.fileSize", "File Size Limit"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.automation", "Automate tool workflows"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.api", "Monthly API Credits"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.priority", "Priority Support"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [t, plans]);
|
||||
|
||||
return {
|
||||
plans: computedPlans,
|
||||
loading: plansLoading,
|
||||
error: plansError,
|
||||
refetch: refreshPlans,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect } from "react";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
|
||||
/**
|
||||
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
|
||||
* Only saves files that have a localFilePath (came from disk)
|
||||
* Matches WorkbenchBar button behavior: saves selected files if any, otherwise all files
|
||||
*/
|
||||
export function useSaveShortcut() {
|
||||
const { selectors, state } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
// Check for Ctrl+S (Windows/Linux) or Cmd+S (Mac)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
|
||||
// Get selected files or all files if nothing selected
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const filesToSave =
|
||||
selectedFileIds.length > 0
|
||||
? selectors.getFiles(selectedFileIds)
|
||||
: selectors.getFiles();
|
||||
const stubsToSave =
|
||||
selectedFileIds.length > 0
|
||||
? selectors.getStirlingFileStubs(selectedFileIds)
|
||||
: selectors.getStirlingFileStubs();
|
||||
|
||||
if (filesToSave.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save files (Save As for files without localFilePath)
|
||||
for (let i = 0; i < filesToSave.length; i++) {
|
||||
const file = filesToSave[i];
|
||||
const stub = stubsToSave[i];
|
||||
if (!stub) continue;
|
||||
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: stub.localFilePath,
|
||||
});
|
||||
|
||||
// Mark file as clean after successful save
|
||||
if (result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to save ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectors, state.ui.selectedFileIds, fileActions]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
export interface SelfHostedAuthState {
|
||||
isSelfHosted: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether the desktop app is in self-hosted mode with an active
|
||||
* authenticated session. Refetches app config when the mode first transitions
|
||||
* to selfhosted, since the jwt-available config fetch fires against the local
|
||||
* bundled backend before the SetupWizard has switched the mode.
|
||||
*/
|
||||
export function useSelfHostedAuth(): SelfHostedAuthState {
|
||||
const { refetch } = useAppConfig();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isSelfHosted, setIsSelfHosted] = useState(false);
|
||||
const wasSelfHosted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService
|
||||
.getCurrentMode()
|
||||
.then((mode) => setIsSelfHosted(mode === "selfhosted"));
|
||||
return connectionModeService.subscribeToModeChanges((cfg) =>
|
||||
setIsSelfHosted(cfg.mode === "selfhosted"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void authService.isAuthenticated().then(setIsAuthenticated);
|
||||
return authService.subscribeToAuth((status) =>
|
||||
setIsAuthenticated(status === "authenticated"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelfHosted && !wasSelfHosted.current) {
|
||||
void refetch();
|
||||
}
|
||||
wasSelfHosted.current = isSelfHosted;
|
||||
}, [isSelfHosted, refetch]);
|
||||
|
||||
return { isSelfHosted, isAuthenticated };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
|
||||
/**
|
||||
* Desktop implementation of useSelfHostedToolAvailability.
|
||||
* Returns the set of tool IDs that are unavailable when the self-hosted server
|
||||
* is offline (tools whose endpoints are not supported by the local bundled backend).
|
||||
*
|
||||
* Returns an empty set when:
|
||||
* - Not in self-hosted mode
|
||||
* - Self-hosted server is online
|
||||
* - Local backend port is not yet known
|
||||
*/
|
||||
export function useSelfHostedToolAvailability(
|
||||
tools: Array<{ id: string; endpoints?: string[] }>,
|
||||
): Set<string> {
|
||||
const [unavailableIds, setUnavailableIds] = useState<Set<string>>(new Set());
|
||||
// Keep a stable ref to the latest tools list to avoid unnecessary re-subscriptions
|
||||
const toolsRef = useRef(tools);
|
||||
toolsRef.current = tools;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const computeUnavailableTools = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== "selfhosted") {
|
||||
setUnavailableIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status !== "offline") {
|
||||
// Idle or checking — not yet confirmed offline; don't mark anything unavailable
|
||||
if (!cancelled) setUnavailableIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (!localUrl) {
|
||||
// Local backend port not yet known; can't determine unavailable tools yet
|
||||
if (!cancelled) setUnavailableIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
// For each tool, check whether at least one of its endpoints is supported locally
|
||||
const unavailable = new Set<string>();
|
||||
await Promise.all(
|
||||
toolsRef.current.map(async (tool) => {
|
||||
const endpoints = tool.endpoints ?? [];
|
||||
if (endpoints.length === 0) return; // No endpoints → always available
|
||||
|
||||
const locallySupported = await Promise.all(
|
||||
endpoints.map((ep) =>
|
||||
endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
ep,
|
||||
localUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!locallySupported.some(Boolean)) {
|
||||
unavailable.add(tool.id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!cancelled) setUnavailableIds(unavailable);
|
||||
};
|
||||
|
||||
// Re-compute when server status changes
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
void computeUnavailableTools();
|
||||
});
|
||||
|
||||
// Re-compute when local backend becomes healthy (port discovered)
|
||||
const unsubBackend = tauriBackendService.subscribeToStatus(() => {
|
||||
void computeUnavailableTools();
|
||||
});
|
||||
|
||||
// Initial computation
|
||||
void computeUnavailableTools();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubServer();
|
||||
unsubBackend();
|
||||
};
|
||||
}, []); // tools intentionally omitted — accessed via ref to avoid churn
|
||||
|
||||
return unavailableIds;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
|
||||
import type { SharingEnabledResult } from "@core/hooks/useSharingEnabled";
|
||||
|
||||
/**
|
||||
* Desktop override: file-sharing features require self-hosted mode AND an
|
||||
* authenticated session. Returns false for both in SaaS/local mode or when
|
||||
* logged out.
|
||||
*/
|
||||
export function useSharingEnabled(): SharingEnabledResult {
|
||||
const { config } = useAppConfig();
|
||||
const { isSelfHosted, isAuthenticated } = useSelfHostedAuth();
|
||||
const allowed = isSelfHosted && isAuthenticated;
|
||||
return {
|
||||
sharingEnabled: allowed && config?.storageSharingEnabled === true,
|
||||
shareLinksEnabled: allowed && config?.storageShareLinksEnabled === true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
|
||||
/**
|
||||
* Desktop hook to check if a tool endpoint will use cloud backend
|
||||
* @param endpointName - The endpoint name to check (e.g., 'ocr-pdf', 'compress-pdf')
|
||||
* @returns true if the tool will use cloud credits, false otherwise
|
||||
*/
|
||||
export function useToolCloudStatus(endpointName?: string): boolean {
|
||||
const [usesCloud, setUsesCloud] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCloudRouting = async () => {
|
||||
if (!endpointName) {
|
||||
setUsesCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Don't show cloud badges until backend is healthy
|
||||
// This prevents showing incorrect cloud status during startup
|
||||
if (!tauriBackendService.isOnline) {
|
||||
setUsesCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if in SaaS mode
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== "saas") {
|
||||
setUsesCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if supported on SaaS first (if not, no point showing cloud badge)
|
||||
const supportedOnSaaS =
|
||||
await endpointAvailabilityService.isEndpointSupportedOnSaaS(
|
||||
endpointName,
|
||||
);
|
||||
|
||||
if (!supportedOnSaaS) {
|
||||
// Not available on SaaS, don't show cloud badge
|
||||
setUsesCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Available on SaaS, check if also available locally
|
||||
const supportedLocally =
|
||||
await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointName,
|
||||
tauriBackendService.getBackendUrl(),
|
||||
);
|
||||
// Show cloud badge only if SaaS supports it but local doesn't
|
||||
setUsesCloud(!supportedLocally);
|
||||
} catch {
|
||||
setUsesCloud(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkCloudRouting();
|
||||
|
||||
// Subscribe to backend status changes to re-check when backend becomes healthy
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === "healthy") {
|
||||
checkCloudRouting();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [endpointName]);
|
||||
|
||||
return usesCloud;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useViewerKeyCommand(): (event: KeyboardEvent) => boolean {
|
||||
const { rotationActions } = useViewer();
|
||||
return useCallback(
|
||||
(event: KeyboardEvent): boolean => {
|
||||
switch (event.key) {
|
||||
case "r":
|
||||
case "R":
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
rotationActions.rotateBackward();
|
||||
} else {
|
||||
rotationActions.rotateForward();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[rotationActions],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
|
||||
/**
|
||||
* Desktop hook to detect if an operation will use cloud/SaaS backend
|
||||
* @param endpoint - The API endpoint to check (e.g., '/api/v1/misc/compress-pdf')
|
||||
* @returns true if the operation will use cloud credits, false otherwise
|
||||
*/
|
||||
export function useWillUseCloud(endpoint?: string): boolean {
|
||||
const [willUseCloud, setWillUseCloud] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCloudRouting = async () => {
|
||||
if (!endpoint) {
|
||||
setWillUseCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show cloud badges until backend is healthy
|
||||
// This prevents showing incorrect cloud status during startup
|
||||
if (!tauriBackendService.isOnline) {
|
||||
setWillUseCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this endpoint will route to SaaS
|
||||
try {
|
||||
const willRoute = await operationRouter.willRouteToSaaS(endpoint);
|
||||
setWillUseCloud(willRoute);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useWillUseCloud] Failed to check cloud routing for endpoint:",
|
||||
endpoint,
|
||||
error,
|
||||
);
|
||||
setWillUseCloud(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkCloudRouting();
|
||||
|
||||
// Subscribe to backend status changes to re-check when backend becomes healthy
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === "healthy") {
|
||||
checkCloudRouting();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [endpoint]);
|
||||
|
||||
return willUseCloud;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
|
||||
/**
|
||||
* Desktop override of Landing.
|
||||
* In desktop builds, authentication is managed entirely by AppProviders,
|
||||
* the DesktopOnboardingModal, and the SignInModal — never by routing to /login.
|
||||
* Always render the main app; the onboarding/sign-in modals appear on top
|
||||
* when authentication is required.
|
||||
*/
|
||||
export default function Landing() {
|
||||
return <HomePage />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Desktop override of the /login route.
|
||||
* The legacy web login page must never appear in desktop builds — authentication
|
||||
* is handled exclusively through the DesktopOnboardingModal and SignInModal.
|
||||
* Any navigation to /login (e.g. from Spring Boot auth redirects) is intercepted
|
||||
* here and immediately redirected to /.
|
||||
* The sign-in modal is opened by the desktop httpErrorHandler before navigation
|
||||
* occurs, so no additional dispatch is needed here.
|
||||
*/
|
||||
export default function Login() {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
|
||||
interface LoginHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
centerOnly?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop override of LoginHeader.
|
||||
* Renders icon + title + optional close button all in one row.
|
||||
*/
|
||||
export default function LoginHeader({
|
||||
title,
|
||||
subtitle,
|
||||
centerOnly = false,
|
||||
onClose,
|
||||
}: LoginHeaderProps) {
|
||||
const { tooltipLogo } = useLogoAssets();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`login-header${centerOnly ? " login-header-centered" : ""}`}
|
||||
style={{ marginBottom: "2rem" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.6rem",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={tooltipLogo}
|
||||
alt="Stirling PDF"
|
||||
style={{ width: 36, height: 36, flexShrink: 0 }}
|
||||
/>
|
||||
{title && (
|
||||
<h1 className="login-title" style={{ margin: 0 }}>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
radius="md"
|
||||
size={32}
|
||||
variant="subtle"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--text-secondary)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <p className="login-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Desktop-specific API client using Tauri's native HTTP client
|
||||
* This file overrides @core/services/apiClient.ts for desktop builds
|
||||
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
|
||||
*/
|
||||
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { create } from "@app/services/tauriHttpClient";
|
||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||
import { setupApiInterceptors } from "@app/services/apiClientSetup";
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
|
||||
// Create Tauri HTTP client with default config
|
||||
const apiClient = create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
responseType: "json",
|
||||
withCredentials: false, // Desktop doesn't need credentials
|
||||
});
|
||||
|
||||
// Setup interceptors (desktop-specific auth and backend ready checks)
|
||||
// Cast to AxiosInstance - Tauri client has compatible API
|
||||
setupApiInterceptors(apiClient as unknown as AxiosInstance);
|
||||
|
||||
// ---------- Install error interceptor ----------
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------- Exports ----------
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* Desktop override: Determine base URL depending on Tauri environment
|
||||
*
|
||||
* Priority (non-Tauri mode):
|
||||
* 1. window.STIRLING_PDF_API_BASE_URL (runtime override - fixes hardcoded localhost issues)
|
||||
* 2. import.meta.env.VITE_API_BASE_URL (build-time env var)
|
||||
* 3. '/' (relative path - works for same-origin deployments)
|
||||
*
|
||||
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
|
||||
* based on connection mode and backend port. This initial baseURL is overridden
|
||||
* by request interceptors in apiClientSetup.ts.
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (!isTauri()) {
|
||||
// Runtime override to fix hardcoded localhost in builds
|
||||
if (typeof window !== "undefined" && window.STIRLING_PDF_API_BASE_URL) {
|
||||
return window.STIRLING_PDF_API_BASE_URL;
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_API_BASE_URL;
|
||||
}
|
||||
|
||||
// In Tauri mode, return empty string as placeholder
|
||||
// The actual URL will be set dynamically by operationRouter based on:
|
||||
// - Offline mode: dynamic port from tauriBackendService
|
||||
// - Server mode: configured server URL from connectionModeService
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { createBackendNotReadyError } from "@app/constants/backendErrors";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import {
|
||||
STIRLING_SAAS_URL,
|
||||
STIRLING_SAAS_BACKEND_API_URL,
|
||||
} from "@app/constants/connection";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||
let lastBackendToast = 0;
|
||||
|
||||
// Extended config for custom properties
|
||||
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
|
||||
operationName?: string;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
skipAuthRedirect?: boolean;
|
||||
_retry?: boolean;
|
||||
_isSaaSRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-specific API interceptors
|
||||
* - Reuses the core interceptors
|
||||
* - Dynamically sets base URL based on connection mode
|
||||
* - Adds auth token for remote server requests
|
||||
* - Blocks API calls while the bundled backend is still starting
|
||||
* - Handles auth token refresh on 401 errors
|
||||
*/
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
coreSetup(client);
|
||||
|
||||
// Request interceptor: Set base URL and auth headers dynamically
|
||||
client.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const extendedConfig = config as ExtendedRequestConfig;
|
||||
|
||||
// IMPORTANT: Check backend readiness BEFORE modifying URL
|
||||
// Pattern matching in shouldSkipBackendReadyCheck() needs original relative URL
|
||||
const originalUrl = extendedConfig.url;
|
||||
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
|
||||
const skipForSaaSBackend =
|
||||
await operationRouter.shouldSkipBackendReadyCheck(originalUrl);
|
||||
|
||||
try {
|
||||
// Get the appropriate base URL for this request
|
||||
const baseUrl = await operationRouter.getBaseUrl(originalUrl);
|
||||
|
||||
// Build the full URL
|
||||
if (extendedConfig.url && !extendedConfig.url.startsWith("http")) {
|
||||
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||
|
||||
// Determine if this request needs authentication
|
||||
// - Local bundled backend: No auth (security disabled)
|
||||
// - SaaS backend: Needs auth token
|
||||
// - Self-hosted backend: Needs auth token
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
const isSaaSBackendRequest = baseUrl === STIRLING_SAAS_BACKEND_API_URL;
|
||||
const needsAuth = isRemote || isSaaSBackendRequest;
|
||||
|
||||
// Tag request so error handler can identify SaaS backend errors without URL matching
|
||||
extendedConfig._isSaaSRequest = isSaaSBackendRequest;
|
||||
|
||||
console.debug(
|
||||
`[apiClientSetup] Auth check: isRemote=${isRemote}, isSaaSBackendRequest=${isSaaSBackendRequest}, needsAuth=${needsAuth}, baseUrl=${baseUrl}`,
|
||||
);
|
||||
|
||||
if (needsAuth) {
|
||||
// Enable credentials for session management
|
||||
extendedConfig.withCredentials = true;
|
||||
|
||||
// If another request is already refreshing, wait before attaching token
|
||||
await authService.awaitRefreshIfInProgress();
|
||||
const token = await authService.getAuthToken();
|
||||
|
||||
if (token) {
|
||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||
console.debug(
|
||||
`[apiClientSetup] Added auth token for request to: ${extendedConfig.url}`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[apiClientSetup] No auth token available for: ${extendedConfig.url}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Local bundled backend: disable credentials (security disabled)
|
||||
extendedConfig.withCredentials = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[apiClientSetup] Error in request interceptor:", error);
|
||||
// Continue with request even if routing/auth logic fails
|
||||
// This ensures requests aren't blocked by interceptor errors
|
||||
}
|
||||
|
||||
// Backend readiness check (for local backend)
|
||||
const isSaaS = await operationRouter.isSaaSMode();
|
||||
const backendHealthy = tauriBackendService.isOnline;
|
||||
const backendStatus = tauriBackendService.getBackendStatus();
|
||||
const backendPort = tauriBackendService.getBackendPort();
|
||||
|
||||
console.debug(
|
||||
`[apiClientSetup] Backend readiness check for ${extendedConfig.url}: isSaaS=${isSaaS}, skipCheck=${skipCheck}, skipForSaaSBackend=${skipForSaaSBackend}, backendHealthy=${backendHealthy}, backendStatus=${backendStatus}, backendPort=${backendPort}`,
|
||||
);
|
||||
|
||||
if (isSaaS && !skipCheck && !skipForSaaSBackend && !backendHealthy) {
|
||||
const method = (extendedConfig.method || "get").toLowerCase();
|
||||
if (method !== "get") {
|
||||
const now = Date.now();
|
||||
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||
lastBackendToast = now;
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: i18n.t("backendHealth.offline", "Backend Offline"),
|
||||
body: i18n.t(
|
||||
"backendHealth.wait",
|
||||
"Please wait for the backend to finish launching and try again.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Promise.reject(createBackendNotReadyError());
|
||||
}
|
||||
|
||||
return extendedConfig;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// Response interceptor: Handle auth errors and update credits from headers
|
||||
client.interceptors.response.use(
|
||||
async (response) => {
|
||||
// Check for credit balance update in response headers
|
||||
// Backend includes X-Credits-Remaining header after operations that consume credits
|
||||
const creditsHeader = response.headers["x-credits-remaining"];
|
||||
|
||||
if (creditsHeader !== undefined && creditsHeader !== null) {
|
||||
const creditsRemaining = parseInt(creditsHeader, 10);
|
||||
|
||||
if (!isNaN(creditsRemaining)) {
|
||||
// Dispatch event with new balance for immediate update
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("credits:updated", {
|
||||
detail: { creditsRemaining },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config as ExtendedRequestConfig;
|
||||
const requestUrl = String(originalRequest?.url || "");
|
||||
const isAuthProbeRequest = requestUrl.includes("/api/v1/auth/me");
|
||||
|
||||
// Handle 401 Unauthorized - try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
// `/auth/me` is used as a probe by session bootstrap; refreshing here can
|
||||
// create recursion (refresh -> save token -> jwt-available -> /auth/me).
|
||||
if (isAuthProbeRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
console.warn(
|
||||
"[apiClientSetup] 401 on path:",
|
||||
window.location.pathname,
|
||||
"url:",
|
||||
originalRequest.url,
|
||||
);
|
||||
}
|
||||
if (originalRequest.skipAuthRedirect) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// If no Authorization header was sent, the user was never authenticated —
|
||||
// the 401 is expected (e.g. endpoint availability checks when not signed in).
|
||||
// Don't attempt a refresh or open the sign-in modal in that case.
|
||||
if (!originalRequest.headers.Authorization) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
originalRequest._retry = true;
|
||||
|
||||
console.debug(
|
||||
`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`,
|
||||
);
|
||||
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
let refreshed = false;
|
||||
|
||||
if (isRemote) {
|
||||
// Self-hosted mode: use Spring Boot refresh endpoint
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (serverConfig) {
|
||||
refreshed = await authService.refreshToken(serverConfig.url);
|
||||
}
|
||||
} else {
|
||||
// SaaS mode: use Supabase refresh endpoint
|
||||
refreshed = await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
|
||||
}
|
||||
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
const token = await authService.getAuthToken();
|
||||
console.debug(
|
||||
`[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`,
|
||||
);
|
||||
|
||||
if (token) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.error(
|
||||
`[apiClientSetup] No token available after successful refresh!`,
|
||||
);
|
||||
}
|
||||
|
||||
return client.request(originalRequest);
|
||||
}
|
||||
|
||||
// Refresh failed - prompt for re-authentication via the sign-in modal.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle 403 Forbidden - unauthorized access
|
||||
if (error.response?.status === 403) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: i18n.t("auth.accessDenied", "Access Denied"),
|
||||
body: i18n.t(
|
||||
"auth.insufficientPermissions",
|
||||
"You do not have permission to perform this action.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const TOKEN_KEY = "stirling_jwt";
|
||||
|
||||
/**
|
||||
* Read auth token from any available source (Tauri store or localStorage).
|
||||
* Kept separate to avoid circular dependencies between auth and backend services.
|
||||
*/
|
||||
export async function getAuthTokenFromAnySource(): Promise<string | null> {
|
||||
// Try Tauri store first
|
||||
try {
|
||||
const token = await invoke<string | null>("get_auth_token");
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Desktop AuthTokenStore] Failed to read from Tauri store:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Desktop AuthTokenStore] Failed to read from localStorage:",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user