mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 20:25:28 +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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user