mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes Changes the strategy for autoformatting to reject PRs if they are not formatted correctly instead of allowing them to merge and then spawning a new PR to fix the formatting. The old strategy just caused more work for us because we'd have to manually approve the followup PR and get it merged, which required 2 reviewers so in practice it rarely got done and just meant everyone's PRs ended up containing reformatting for unrelated files, which makes code review unnecessarily difficult. If the PR's code is not formatted correctly after this PR, a comment will be added automatically to tell the author how to run the formatter script to fix their code so it can go in. This also enables autoformatting for the frontend code, using Prettier. I've enabled it for pretty much everything in the frontend folder, other than 3rd party files and files it doesn't make sense for. I also excluded Markdown because it sounds likely to be more annoying to have to autoformat the Markdown in the frontend folder but nowhere else. Open to changing this though if people disagree. > [!note] > > Advice to reviewers: The first commit contains all of the actual logic I've introduced (CI changes, Prettier config, etc.) > The second commit is just the reformatting of the entire frontend folder. > The first commit needs proper review, the second one just give it a spot-check that it's doing what you'd expect.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { STIRLING_SAAS_URL, SUPABASE_KEY } from "@app/constants/connection";
|
||||
|
||||
/**
|
||||
* Supabase client for desktop application
|
||||
@@ -10,21 +10,17 @@ import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
|
||||
*/
|
||||
|
||||
if (!STIRLING_SAAS_URL) {
|
||||
console.warn('[Desktop Supabase] VITE_SAAS_SERVER_URL not configured - SaaS features will not work');
|
||||
console.warn("[Desktop Supabase] VITE_SAAS_SERVER_URL not configured - SaaS features will not work");
|
||||
}
|
||||
|
||||
if (!SUPABASE_KEY) {
|
||||
console.warn('[Desktop Supabase] VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY not configured - SaaS features will not work');
|
||||
console.warn("[Desktop Supabase] VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY not configured - SaaS features will not work");
|
||||
}
|
||||
|
||||
export const supabase = createClient(
|
||||
STIRLING_SAAS_URL || '',
|
||||
SUPABASE_KEY || '',
|
||||
{
|
||||
auth: {
|
||||
persistSession: false, // Desktop manages auth via authService + Tauri secure store
|
||||
autoRefreshToken: false, // Desktop manually refreshes tokens via authService
|
||||
detectSessionInUrl: false, // Desktop uses deep links, not URL hash fragments
|
||||
},
|
||||
}
|
||||
);
|
||||
export const supabase = createClient(STIRLING_SAAS_URL || "", SUPABASE_KEY || "", {
|
||||
auth: {
|
||||
persistSession: false, // Desktop manages auth via authService + Tauri secure store
|
||||
autoRefreshToken: false, // Desktop manually refreshes tokens via authService
|
||||
detectSessionInUrl: false, // Desktop uses deep links, not URL hash fragments
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
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';
|
||||
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',
|
||||
"/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",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -45,7 +45,7 @@ const COMMON_TOOL_ENDPOINTS = [
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | 'local' | null>(null);
|
||||
const [connectionMode, setConnectionMode] = useState<"saas" | "selfhosted" | "local" | null>(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [pendingSignIn, setPendingSignIn] = useState(false);
|
||||
// Prevent first-launch setup from running twice when connectionMode state update re-triggers the effect
|
||||
@@ -71,8 +71,8 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
// 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);
|
||||
if (hasLoadedInitialMode.current && config.mode !== "selfhosted") {
|
||||
setAppKey((k) => k + 1);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
@@ -83,33 +83,35 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
if (connectionMode === null) return;
|
||||
|
||||
if (!isFirstLaunch && setupComplete) {
|
||||
if (connectionMode === 'local') {
|
||||
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()
|
||||
authService
|
||||
.isAuthenticated()
|
||||
.then(async (isAuth) => {
|
||||
if (isAuth) {
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL).catch(console.error);
|
||||
setConnectionMode('saas');
|
||||
setConnectionMode("saas");
|
||||
}
|
||||
})
|
||||
.finally(() => setAuthChecked(true));
|
||||
} else {
|
||||
authService.isAuthenticated()
|
||||
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');
|
||||
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');
|
||||
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
|
||||
setPendingSignIn(true);
|
||||
}
|
||||
}
|
||||
@@ -121,9 +123,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
|
||||
if (!cfg?.lock_connection_mode) {
|
||||
await connectionModeService.switchToLocal().catch(console.error);
|
||||
setConnectionMode('local');
|
||||
setConnectionMode("local");
|
||||
if (!localStorage.getItem(JWT_EXPIRED_PROMPTED_KEY)) {
|
||||
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, 'true');
|
||||
localStorage.setItem(JWT_EXPIRED_PROMPTED_KEY, "true");
|
||||
setPendingSignIn(true);
|
||||
}
|
||||
}
|
||||
@@ -134,20 +136,21 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
// Guard against re-running when setConnectionMode triggers this effect.
|
||||
if (firstLaunchInitiated.current) return;
|
||||
firstLaunchInitiated.current = true;
|
||||
connectionModeService.getCurrentConfig()
|
||||
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');
|
||||
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');
|
||||
setConnectionMode("local");
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
@@ -157,14 +160,14 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
|
||||
// Initialize backend health monitoring for self-hosted mode
|
||||
useEffect(() => {
|
||||
if (connectionMode !== 'selfhosted') {
|
||||
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 => {
|
||||
connectionModeService.getServerConfig().then((cfg) => {
|
||||
if (cfg?.url) {
|
||||
selfHostedServerMonitor.start(cfg.url);
|
||||
}
|
||||
@@ -177,7 +180,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
|
||||
// Initialize monitoring for bundled backend (already started in Rust)
|
||||
// This sets up port detection and health checks
|
||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && (connectionMode === 'saas' || connectionMode === 'local');
|
||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && (connectionMode === "saas" || connectionMode === "local");
|
||||
useBackendInitializer(shouldMonitorBackend);
|
||||
|
||||
// Preload endpoint availability for the local bundled backend.
|
||||
@@ -186,9 +189,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
// (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');
|
||||
(setupComplete && !isFirstLaunch && connectionMode === "saas") ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === "local") ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === "selfhosted");
|
||||
useEffect(() => {
|
||||
if (!shouldPreloadLocalEndpoints) return;
|
||||
|
||||
@@ -198,7 +201,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
// tauriBackendService.isOnline now always reflects the local backend.
|
||||
// Wait for it to be healthy before preloading in both modes.
|
||||
if (!tauriBackendService.isOnline) return;
|
||||
console.debug('[AppProviders] Preloading common tool endpoints for local backend');
|
||||
console.debug("[AppProviders] Preloading common tool endpoints for local backend");
|
||||
void endpointAvailabilityService.preloadEndpoints(COMMON_TOOL_ENDPOINTS, backendUrl);
|
||||
};
|
||||
|
||||
@@ -207,7 +210,6 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
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
|
||||
@@ -246,11 +248,11 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: 'non-blocking',
|
||||
bootstrapMode: "non-blocking",
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<div style={{ minHeight: '100vh' }} />
|
||||
<div style={{ minHeight: "100vh" }} />
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
@@ -264,13 +266,15 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: 'non-blocking',
|
||||
bootstrapMode: "non-blocking",
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<ToolActionsContext.Provider value={{
|
||||
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
|
||||
}}>
|
||||
<ToolActionsContext.Provider
|
||||
value={{
|
||||
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
|
||||
}}
|
||||
>
|
||||
<SaaSTeamProvider key={appKey}>
|
||||
<SaasBillingProvider>
|
||||
<SaaSCheckoutProvider>
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Tooltip, useMantineTheme, useComputedColorScheme, rem } from '@mantine/core';
|
||||
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Tooltip, useMantineTheme, useComputedColorScheme, rem } from "@mantine/core";
|
||||
import { useBackendHealth } from "@app/hooks/useBackendHealth";
|
||||
|
||||
interface BackendHealthIndicatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
className = ''
|
||||
}) => {
|
||||
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({ className = "" }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const { status, isOnline, checkHealth } = useBackendHealth();
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (status === 'starting') {
|
||||
return t('backendHealth.checking', 'Checking backend status...');
|
||||
if (status === "starting") {
|
||||
return t("backendHealth.checking", "Checking backend status...");
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
return t('backendHealth.online', 'Backend Online');
|
||||
return t("backendHealth.online", "Backend Online");
|
||||
}
|
||||
|
||||
return t('backendHealth.offline', 'Backend Offline');
|
||||
return t("backendHealth.offline", "Backend Offline");
|
||||
}, [status, isOnline, t]);
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
if (status === 'starting') {
|
||||
return theme.colors.yellow?.[5] ?? '#fcc419';
|
||||
if (status === "starting") {
|
||||
return theme.colors.yellow?.[5] ?? "#fcc419";
|
||||
}
|
||||
if (isOnline) {
|
||||
return theme.colors.green?.[5] ?? '#37b24d';
|
||||
return theme.colors.green?.[5] ?? "#37b24d";
|
||||
}
|
||||
return theme.colors.red?.[6] ?? '#e03131';
|
||||
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]);
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
checkHealth();
|
||||
}
|
||||
},
|
||||
[checkHealth],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -51,7 +52,7 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
offset={12}
|
||||
withArrow
|
||||
withinPortal
|
||||
color={colorScheme === 'dark' ? undefined : 'dark'}
|
||||
color={colorScheme === "dark" ? undefined : "dark"}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
@@ -65,14 +66,12 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
style={{
|
||||
width: rem(12),
|
||||
height: rem(12),
|
||||
borderRadius: '50%',
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dotColor,
|
||||
boxShadow: colorScheme === 'dark'
|
||||
? '0 0 0 2px rgba(255, 255, 255, 0.18)'
|
||||
: '0 0 0 2px rgba(0, 0, 0, 0.08)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-block',
|
||||
outline: 'none',
|
||||
boxShadow: colorScheme === "dark" ? "0 0 0 2px rgba(255, 255, 255, 0.18)" : "0 0 0 2px rgba(0, 0, 0, 0.08)",
|
||||
cursor: "pointer",
|
||||
display: "inline-block",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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();
|
||||
@@ -17,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(currentConfig);
|
||||
|
||||
if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
|
||||
if (currentConfig.mode === "saas" || currentConfig.mode === "selfhosted") {
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
}
|
||||
@@ -33,8 +33,8 @@ export const ConnectionSettings: React.FC = () => {
|
||||
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);
|
||||
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
|
||||
@@ -46,11 +46,11 @@ export const ConnectionSettings: React.FC = () => {
|
||||
setUserInfo(null);
|
||||
|
||||
// Clear URL to home page so we don't return to settings after re-login
|
||||
window.history.replaceState({}, '', '/');
|
||||
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);
|
||||
console.error("Logout failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export const ConnectionSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return <Text>{t('common.loading', 'Loading...')}</Text>;
|
||||
return <Text>{t("common.loading", "Loading...")}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -69,43 +69,40 @@ export const ConnectionSettings: React.FC = () => {
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
|
||||
<Badge
|
||||
color={config.mode === 'saas' ? 'blue' : config.mode === 'local' ? 'white' : 'green'}
|
||||
variant="light"
|
||||
>
|
||||
{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')}
|
||||
<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' && (
|
||||
{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.'
|
||||
"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 && (
|
||||
{(config.mode === "saas" || config.mode === "selfhosted") && config.server_config && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('settings.connection.server', 'Server')}
|
||||
{t("settings.connection.server", "Server")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
|
||||
{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')}
|
||||
{t("settings.connection.user", "Logged in as")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{userInfo.username}
|
||||
@@ -117,13 +114,13 @@ export const ConnectionSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
<Group mt="md">
|
||||
{config.mode === 'local' ? (
|
||||
{config.mode === "local" ? (
|
||||
<Button onClick={handleSignIn} color="blue" variant="light">
|
||||
{t('settings.connection.signIn', 'Sign In')}
|
||||
{t("settings.connection.signIn", "Sign In")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Log Out')}
|
||||
{t("settings.connection.logout", "Log Out")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
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
|
||||
@@ -13,7 +13,7 @@ export function DesktopConfigSync() {
|
||||
const previousStatus = useRef(status);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'healthy' && previousStatus.current !== 'healthy') {
|
||||
if (status === "healthy" && previousStatus.current !== "healthy") {
|
||||
refetch();
|
||||
}
|
||||
previousStatus.current = status;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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';
|
||||
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 ONBOARDING_KEY = "stirling-desktop-onboarding-seen";
|
||||
|
||||
const SIGN_IN_GRADIENT: [string, string] = ['#3B82F6', '#7C3AED'];
|
||||
const SIGN_IN_GRADIENT: [string, string] = ["#3B82F6", "#7C3AED"];
|
||||
|
||||
/**
|
||||
* Desktop-specific onboarding modal.
|
||||
@@ -26,7 +26,7 @@ export function DesktopOnboardingModal() {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const dismissFinal = () => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
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.
|
||||
@@ -43,14 +43,13 @@ export function DesktopOnboardingModal() {
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
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(), []);
|
||||
@@ -71,38 +70,42 @@ export function DesktopOnboardingModal() {
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
background: 'var(--bg-surface)',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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' }}>
|
||||
<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) as [string, string]}
|
||||
circles={welcomeSlide.background.circles}
|
||||
isActive
|
||||
slideKey={step === 0 ? 'desktop-welcome' : 'desktop-sign-in'}
|
||||
slideKey={step === 0 ? "desktop-welcome" : "desktop-sign-in"}
|
||||
/>
|
||||
<ActionIcon
|
||||
onClick={handleClose}
|
||||
radius="md"
|
||||
size={36}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(4px)',
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
color: "white",
|
||||
backdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
styles={{ root: { '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.3)' } } }}
|
||||
styles={{ root: { "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.3)" } } }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
@@ -118,20 +121,13 @@ export function DesktopOnboardingModal() {
|
||||
</div>
|
||||
|
||||
{/* Body section */}
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}
|
||||
>
|
||||
<div className={styles.modalBody} style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
|
||||
{step === 0 ? (
|
||||
// Welcome slide
|
||||
<Stack gap={16}>
|
||||
<div className={`${styles.title} ${styles.titleText}`}>
|
||||
{welcomeSlide.title}
|
||||
</div>
|
||||
<div className={`${styles.title} ${styles.titleText}`}>{welcomeSlide.title}</div>
|
||||
<div className={styles.bodyText}>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{welcomeSlide.body}
|
||||
</div>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>{welcomeSlide.body}</div>
|
||||
<style>{`.${styles.bodyCopyInner} strong { color: var(--onboarding-title); font-weight: 600; }`}</style>
|
||||
</div>
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
|
||||
@@ -141,12 +137,12 @@ export function DesktopOnboardingModal() {
|
||||
onClick={() => setStep(1)}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
background: "var(--onboarding-primary-button-bg)",
|
||||
color: "var(--onboarding-primary-button-text)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('onboarding.buttons.next', 'Next →')}
|
||||
{t("onboarding.buttons.next", "Next →")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
@@ -155,11 +151,7 @@ export function DesktopOnboardingModal() {
|
||||
// Sign-in slide
|
||||
<Stack gap={12}>
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
|
||||
<SetupWizard
|
||||
noLayout
|
||||
onComplete={handleComplete}
|
||||
onClose={dismissFinal}
|
||||
/>
|
||||
<SetupWizard noLayout onComplete={handleComplete} onClose={dismissFinal} />
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSaveShortcut } from '@app/hooks/useSaveShortcut';
|
||||
import { useExitWarning } from '@app/hooks/useExitWarning';
|
||||
import { useSaveShortcut } from "@app/hooks/useSaveShortcut";
|
||||
import { useExitWarning } from "@app/hooks/useExitWarning";
|
||||
|
||||
/**
|
||||
* Desktop-only component that sets up keyboard shortcuts and exit warnings
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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;
|
||||
@@ -19,15 +19,15 @@ export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }
|
||||
// Force light mode on auth pages
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
|
||||
const previousColorScheme = htmlElement.getAttribute("data-mantine-color-scheme");
|
||||
|
||||
// Set light mode
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
|
||||
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);
|
||||
htmlElement.setAttribute("data-mantine-color-scheme", previousColorScheme);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -44,28 +44,21 @@ export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
>
|
||||
<div ref={cardRef} className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>
|
||||
{children}
|
||||
</div>
|
||||
<div className={styles.authContent}>{children}</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
{!hideRightPanel && <LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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';
|
||||
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';
|
||||
type KnownProviderId = "google" | "github" | "keycloak" | "azure" | "apple" | "oidc";
|
||||
export type OAuthProviderId = KnownProviderId | string;
|
||||
|
||||
export interface DesktopSSOProvider {
|
||||
@@ -21,7 +21,7 @@ interface DesktopOAuthButtonsProps {
|
||||
isDisabled: boolean;
|
||||
serverUrl: string;
|
||||
providers: DesktopSSOProvider[];
|
||||
mode?: 'saas' | 'selfHosted';
|
||||
mode?: "saas" | "selfHosted";
|
||||
}
|
||||
|
||||
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
@@ -30,7 +30,7 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
isDisabled,
|
||||
serverUrl,
|
||||
providers,
|
||||
mode = 'saas',
|
||||
mode = "saas",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
@@ -46,21 +46,20 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
|
||||
// Build callback page HTML with translations and dark mode support
|
||||
const successHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.success.title', 'Authentication Successful'),
|
||||
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
|
||||
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.'),
|
||||
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 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);
|
||||
@@ -68,11 +67,10 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
// Call the onOAuthSuccess callback to complete setup
|
||||
await onOAuthSuccess(userInfo);
|
||||
} catch (error) {
|
||||
console.error('OAuth login failed:', 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.');
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t("setup.login.error.oauthFailed", "OAuth login failed. Please try again.");
|
||||
|
||||
onError(errorMessage);
|
||||
setOauthLoading(false);
|
||||
@@ -80,22 +78,21 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
};
|
||||
|
||||
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' },
|
||||
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';
|
||||
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);
|
||||
console.log("[DesktopOAuthButtons] Received providers:", providers);
|
||||
console.log("[DesktopOAuthButtons] Mode:", mode, "Server URL:", serverUrl);
|
||||
|
||||
if (providers.length === 0) {
|
||||
console.warn('[DesktopOAuthButtons] No providers to display, returning null');
|
||||
console.warn("[DesktopOAuthButtons] No providers to display, returning null");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -105,15 +102,13 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
{providers
|
||||
.filter((providerConfigEntry) => providerConfigEntry && providerConfigEntry.id)
|
||||
.map((providerEntry) => {
|
||||
const iconConfig = isKnownProvider(providerEntry.id)
|
||||
? providerConfig[providerEntry.id]
|
||||
: undefined;
|
||||
const iconConfig = isKnownProvider(providerEntry.id) ? providerConfig[providerEntry.id] : undefined;
|
||||
const label =
|
||||
providerEntry.label ||
|
||||
iconConfig?.label ||
|
||||
(providerEntry.id
|
||||
? providerEntry.id.charAt(0).toUpperCase() + providerEntry.id.slice(1)
|
||||
: t('setup.login.sso', 'Single Sign-On'));
|
||||
: t("setup.login.sso", "Single Sign-On"));
|
||||
return (
|
||||
<button
|
||||
key={providerEntry.id}
|
||||
@@ -136,8 +131,8 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
);
|
||||
})}
|
||||
{oauthLoading && (
|
||||
<p style={{ margin: '0.5rem 0', fontSize: '0.875rem', color: '#6b7280', textAlign: 'center' }}>
|
||||
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
|
||||
<p style={{ margin: "0.5rem 0", fontSize: "0.875rem", color: "#6b7280", textAlign: "center" }}>
|
||||
{t("setup.login.oauthPending", "Opening browser for authentication...")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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';
|
||||
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;
|
||||
@@ -33,19 +33,19 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
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'));
|
||||
setValidationError(t("setup.login.error.emptyEmail", "Please enter your email"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
setValidationError(t("setup.login.error.emptyPassword", "Please enter your password"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} onClose={onClose} />
|
||||
<LoginHeader title={t("setup.saas.title", "Sign in to Stirling Cloud")} onClose={onClose} />
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
@@ -71,14 +71,11 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
mode="saas"
|
||||
providers={[
|
||||
{ id: 'google' },
|
||||
{ id: 'github' },
|
||||
]}
|
||||
providers={[{ id: "google" }, { id: "github" }]}
|
||||
/>
|
||||
|
||||
<DividerWithText
|
||||
text={t('setup.login.orContinueWith', 'Or continue with email')}
|
||||
text={t("setup.login.orContinueWith", "Or continue with email")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
@@ -96,10 +93,10 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
}}
|
||||
onSubmit={handleEmailPasswordSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
submitButtonText={t("setup.login.submit", "Login")}
|
||||
/>
|
||||
|
||||
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'right' }}>
|
||||
<div className="navigation-link-container" style={{ marginTop: "0.5rem", textAlign: "right" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -109,21 +106,16 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t('signup.signUp', 'Sign Up')}
|
||||
{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')}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
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;
|
||||
@@ -21,9 +21,9 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
|
||||
onSwitchToLogin: _onSwitchToLogin,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
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);
|
||||
@@ -47,12 +47,14 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
|
||||
try {
|
||||
setIsSignupSubmitting(true);
|
||||
await authService.signUpSaas(email.trim(), password);
|
||||
setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.'));
|
||||
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' });
|
||||
const message = err instanceof Error ? err.message : t("signup.unexpectedError", { message: "Unknown error" });
|
||||
setValidationError(message);
|
||||
} finally {
|
||||
setIsSignupSubmitting(false);
|
||||
@@ -61,10 +63,7 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('signup.title', 'Create an account')}
|
||||
subtitle={t('signup.subtitle', 'Join Stirling PDF')}
|
||||
/>
|
||||
<LoginHeader title={t("signup.title", "Create an account")} subtitle={t("signup.subtitle", "Join Stirling PDF")} />
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
{signupSuccessMessage && (
|
||||
@@ -98,7 +97,6 @@ export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
|
||||
showName={false}
|
||||
showTerms={false}
|
||||
/>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
|
||||
interface SelfHostedLinkProps {
|
||||
onClick: () => void;
|
||||
@@ -11,14 +11,9 @@ export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({ onClick, disable
|
||||
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')}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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';
|
||||
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;
|
||||
@@ -26,7 +26,7 @@ interface SelfHostedLoginScreenProps {
|
||||
export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
serverUrl,
|
||||
enabledOAuthProviders,
|
||||
loginMethod = 'all',
|
||||
loginMethod = "all",
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
mfaCode,
|
||||
@@ -36,35 +36,35 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
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';
|
||||
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
|
||||
|
||||
console.log('[SelfHostedLoginScreen] Props:', {
|
||||
console.log("[SelfHostedLoginScreen] Props:", {
|
||||
serverUrl,
|
||||
enabledOAuthProviders,
|
||||
loginMethod,
|
||||
isUserPassAllowed,
|
||||
shouldShowOAuth: !!(enabledOAuthProviders && enabledOAuthProviders.length > 0)
|
||||
shouldShowOAuth: !!(enabledOAuthProviders && enabledOAuthProviders.length > 0),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
setValidationError(t("setup.login.error.emptyUsername", "Please enter your username"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
setValidationError(t("setup.login.error.emptyPassword", "Please enter your password"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresMfa && !mfaCode.trim()) {
|
||||
setValidationError(t('login.mfaRequired', 'Two-factor code required'));
|
||||
setValidationError(t("login.mfaRequired", "Two-factor code required"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,14 +81,17 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('setup.selfhosted.title', 'Sign in to Server')}
|
||||
subtitle={isUserPassAllowed ? t('setup.selfhosted.subtitle', 'Enter your server credentials') : undefined}
|
||||
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>
|
||||
{t("setup.login.connectingTo", "Connecting to:")}{" "}
|
||||
<Text span fw="500">
|
||||
{serverUrl}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{/* Show OAuth buttons if providers are available */}
|
||||
@@ -106,7 +109,7 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
{/* Only show divider if username/password auth is also allowed */}
|
||||
{isUserPassAllowed && (
|
||||
<DividerWithText
|
||||
text={t('setup.login.orContinueWith', 'Or continue with email')}
|
||||
text={t("setup.login.orContinueWith", "Or continue with email")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
@@ -136,7 +139,7 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
requiresMfa={requiresMfa}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
submitButtonText={t("setup.login.submit", "Login")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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;
|
||||
@@ -12,41 +12,43 @@ interface ServerSelectionProps {
|
||||
|
||||
export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
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 serverUrl = localStorage.getItem("server_url") || "";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Normalize and validate URL
|
||||
let url = customUrl.trim().replace(/\/+$/, '') || serverUrl;
|
||||
let url = customUrl.trim().replace(/\/+$/, "") || serverUrl;
|
||||
|
||||
if (!url) {
|
||||
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||
setTestError(t("setup.server.error.emptyUrl", "Please enter a server URL"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-add https:// if no protocol specified
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
console.log('[ServerSelection] No protocol specified, adding https://');
|
||||
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:', {
|
||||
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'));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,19 +63,19 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
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'));
|
||||
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');
|
||||
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)
|
||||
let loginMethod = "all"; // Default to 'all' (allows both SSO and username/password)
|
||||
try {
|
||||
console.log('[ServerSelection] Fetching login configuration...');
|
||||
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)
|
||||
@@ -81,96 +83,92 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
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)');
|
||||
console.log("[ServerSelection] Security/SSO not configured on this server (or endpoint does not exist)");
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
// Other error statuses - show generic error
|
||||
setTestError(
|
||||
t('setup.server.error.configFetch', 'Failed to fetch server configuration (status {{status}})', {
|
||||
status: response.status
|
||||
})
|
||||
t("setup.server.error.configFetch", "Failed to fetch server configuration (status {{status}})", {
|
||||
status: response.status,
|
||||
}),
|
||||
);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[ServerSelection] Login UI data:', data);
|
||||
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');
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
console.warn("[ServerSelection] Skipping provider with empty id:", path);
|
||||
return;
|
||||
}
|
||||
|
||||
enabledProviders.push({
|
||||
id,
|
||||
path,
|
||||
label: typeof label === 'string' ? label : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[ServerSelection] ✅ Detected OAuth providers:', enabledProviders);
|
||||
console.log('[ServerSelection] Login method:', loginMethod);
|
||||
console.log("[ServerSelection] ✅ Detected OAuth providers:", enabledProviders);
|
||||
console.log("[ServerSelection] Login method:", loginMethod);
|
||||
} catch (err) {
|
||||
console.error('[ServerSelection] ❌ Failed to fetch login configuration:', err);
|
||||
console.error("[ServerSelection] ❌ Failed to fetch login configuration:", err);
|
||||
|
||||
// Check if it's a security disabled error
|
||||
if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) {
|
||||
console.log('[ServerSelection] Security is disabled (error-based detection)');
|
||||
if (err instanceof Error && (err.message.includes("403") || err.message.includes("401"))) {
|
||||
console.log("[ServerSelection] Security is disabled (error-based detection)");
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For any other error (network, CORS, invalid JSON, etc.), show error and don't proceed
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('[ServerSelection] Configuration fetch error details:', errorMessage);
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[ServerSelection] Configuration fetch error details:", errorMessage);
|
||||
|
||||
setTestError(
|
||||
t('setup.server.error.configFetchError', 'Failed to fetch server configuration: {{error}}', {
|
||||
error: errorMessage
|
||||
})
|
||||
t("setup.server.error.configFetchError", "Failed to fetch server configuration: {{error}}", {
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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');
|
||||
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')
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -180,7 +178,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('setup.server.url.label', 'Server URL')}
|
||||
label={t("setup.server.url.label", "Server URL")}
|
||||
placeholder="https://your-server.com"
|
||||
value={customUrl}
|
||||
onChange={(e) => {
|
||||
@@ -190,10 +188,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
}}
|
||||
disabled={loading || testing}
|
||||
error={testError}
|
||||
description={t(
|
||||
'setup.server.url.description',
|
||||
'Enter the full URL of your self-hosted Stirling PDF server'
|
||||
)}
|
||||
description={t("setup.server.url.description", "Enter the full URL of your self-hosted Stirling PDF server")}
|
||||
/>
|
||||
|
||||
{securityDisabled && (
|
||||
@@ -201,17 +196,22 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
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')}
|
||||
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:')}
|
||||
{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 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>
|
||||
@@ -228,26 +228,17 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
setCustomUrl(serverUrl);
|
||||
// Auto-submit the form after setting the URL
|
||||
setTimeout(() => {
|
||||
handleSubmit(new Event('submit') as any);
|
||||
handleSubmit(new Event("submit") as any);
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
{t('setup.server.useLast', 'Last used server: {{serverUrl}}', { serverUrl: serverUrl })}
|
||||
{t("setup.server.useLast", "Last used server: {{serverUrl}}", { serverUrl: serverUrl })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={testing || loading}
|
||||
disabled={loading}
|
||||
mt="md"
|
||||
fullWidth
|
||||
color="#AF3434"
|
||||
>
|
||||
{testing
|
||||
? t('setup.server.testing', 'Testing connection...')
|
||||
: t('common.continue', 'Continue')}
|
||||
<Button type="submit" loading={testing || loading} disabled={loading} mt="md" fullWidth color="#AF3434">
|
||||
{testing ? t("setup.server.testing", "Testing connection...") : t("common.continue", "Continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
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;
|
||||
@@ -12,18 +12,14 @@ interface ServerSelectionScreenProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({
|
||||
onSelect,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({ onSelect, loading, error }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('setup.server.title', 'Connect to Server')}
|
||||
subtitle={t('setup.server.subtitle', 'Enter your self-hosted server URL')}
|
||||
title={t("setup.server.title", "Connect to Server")}
|
||||
subtitle={t("setup.server.subtitle", "Enter your self-hosted server URL")}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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';
|
||||
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,
|
||||
@@ -29,14 +29,13 @@ interface SetupWizardProps {
|
||||
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 [selfHostedMfaCode, setSelfHostedMfaCode] = useState("");
|
||||
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
|
||||
const [lockConnectionMode, setLockConnectionMode] = useState(false);
|
||||
const [lockedServerUnreachable, setLockedServerUnreachable] = useState(false);
|
||||
@@ -44,7 +43,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No SaaS server configured');
|
||||
setError("No SaaS server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,15 +62,15 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('SaaS login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'SaaS login failed');
|
||||
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');
|
||||
setError("No SaaS server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,8 +83,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
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');
|
||||
console.error("SaaS OAuth login completion failed:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to complete SaaS login");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -96,13 +95,13 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
setError(null);
|
||||
// Save the server URL so it pre-fills on reconnect
|
||||
if (serverConfig?.url) {
|
||||
localStorage.setItem('server_url', 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);
|
||||
console.error("Failed to continue in local mode:", err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -127,24 +126,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
};
|
||||
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
console.log('[SetupWizard] Server selected:', config);
|
||||
console.log('[SetupWizard] OAuth providers:', config.enabledOAuthProviders);
|
||||
console.log('[SetupWizard] Login method:', config.loginMethod);
|
||||
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('');
|
||||
setSelfHostedMfaCode("");
|
||||
setSelfHostedMfaRequired(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
};
|
||||
|
||||
const handleSelfHostedLogin = async (username: string, password: string) => {
|
||||
console.log('[SetupWizard] 🔐 Starting self-hosted login');
|
||||
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');
|
||||
console.error("[SetupWizard] ❌ No server configured");
|
||||
setError("No server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,54 +151,54 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[SetupWizard] Step 1: Authenticating with server...');
|
||||
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');
|
||||
console.log("[SetupWizard] ✅ Authentication successful");
|
||||
|
||||
setSelfHostedMfaRequired(false);
|
||||
setSelfHostedMfaCode('');
|
||||
setSelfHostedMfaCode("");
|
||||
|
||||
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
|
||||
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] ✅ Switched to self-hosted mode");
|
||||
|
||||
console.log('[SetupWizard] Step 3: Initializing external backend...');
|
||||
console.log("[SetupWizard] Step 3: Initializing external backend...");
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
console.log('[SetupWizard] ✅ External backend initialized');
|
||||
console.log("[SetupWizard] ✅ External backend initialized");
|
||||
|
||||
console.log('[SetupWizard] ✅ Setup complete, calling onComplete()');
|
||||
console.log("[SetupWizard] ✅ Setup complete, calling onComplete()");
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('[SetupWizard] ❌ Self-hosted login failed:', err);
|
||||
let errorMessage = 'Self-hosted login failed';
|
||||
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') {
|
||||
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') {
|
||||
} else if (typeof err === "string") {
|
||||
errorMessage = err;
|
||||
}
|
||||
if (errorMessage.toLowerCase().includes('mfa_required') || errorMessage.toLowerCase().includes('invalid_mfa_code')) {
|
||||
if (errorMessage.toLowerCase().includes("mfa_required") || errorMessage.toLowerCase().includes("invalid_mfa_code")) {
|
||||
setSelfHostedMfaRequired(true);
|
||||
}
|
||||
console.error('[SetupWizard] Error message:', errorMessage);
|
||||
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] 🔐 OAuth login successful, completing setup");
|
||||
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
|
||||
|
||||
if (!serverConfig) {
|
||||
console.error('[SetupWizard] ❌ No server configured');
|
||||
setError('No server configured');
|
||||
console.error("[SetupWizard] ❌ No server configured");
|
||||
setError("No server configured");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,28 +206,28 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[SetupWizard] Step 1: OAuth already completed');
|
||||
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
|
||||
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] ✅ Switched to self-hosted mode");
|
||||
|
||||
console.log('[SetupWizard] Step 3: Initializing external backend...');
|
||||
console.log("[SetupWizard] Step 3: Initializing external backend...");
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
console.log('[SetupWizard] ✅ External backend initialized');
|
||||
console.log("[SetupWizard] ✅ External backend initialized");
|
||||
|
||||
console.log('[SetupWizard] ✅ Setup complete, calling onComplete()');
|
||||
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);
|
||||
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 unsubscribePromise = listen<string>("deep-link", async (event) => {
|
||||
const url = event.payload;
|
||||
if (!url) return;
|
||||
|
||||
@@ -236,24 +235,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Supabase sends tokens in the URL hash
|
||||
const hash = parsed.hash.replace(/^#/, '');
|
||||
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');
|
||||
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 (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 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');
|
||||
console.error("[SetupWizard] Deep link missing token or server for SSO completion");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,12 +266,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
return;
|
||||
}
|
||||
|
||||
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
|
||||
if (!type || (type !== "signup" && type !== "recovery" && type !== "magiclink")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
console.error('[SetupWizard] Deep link missing access_token');
|
||||
console.error("[SetupWizard] Deep link missing access_token");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,8 +283,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
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');
|
||||
console.error("[SetupWizard] Failed to handle deep link", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to complete signup");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
@@ -301,7 +300,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
}
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setSelfHostedMfaCode('');
|
||||
setSelfHostedMfaCode("");
|
||||
setSelfHostedMfaRequired(false);
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
@@ -322,7 +321,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
setLockedServerUnreachable(false);
|
||||
setLockedServerChecking(true);
|
||||
|
||||
const savedUrl = serverUrl.replace(/\/+$/, '');
|
||||
const savedUrl = serverUrl.replace(/\/+$/, "");
|
||||
let updatedConfig: ServerConfig = { ...(currentConfig.server_config ?? { url: savedUrl }) };
|
||||
|
||||
try {
|
||||
@@ -334,12 +333,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
const providerEntries = Object.entries(data.providerList || {});
|
||||
|
||||
providerEntries.forEach(([path, label]) => {
|
||||
const id = path.split('/').pop();
|
||||
const id = path.split("/").pop();
|
||||
if (id) {
|
||||
enabledProviders.push({
|
||||
id,
|
||||
path,
|
||||
label: typeof label === 'string' ? label : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -347,7 +346,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
updatedConfig = {
|
||||
...updatedConfig,
|
||||
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
|
||||
loginMethod: data.loginMethod || 'all',
|
||||
loginMethod: data.loginMethod || "all",
|
||||
};
|
||||
|
||||
setServerConfig(updatedConfig);
|
||||
@@ -355,14 +354,14 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
} else {
|
||||
// Server responded but with an error — still show login form
|
||||
updatedConfig = { ...updatedConfig, loginMethod: 'all' };
|
||||
updatedConfig = { ...updatedConfig, loginMethod: "all" };
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
}
|
||||
} catch (err) {
|
||||
// Network error — server is unreachable
|
||||
console.error('[SetupWizard] Server unreachable:', err);
|
||||
console.error("[SetupWizard] Server unreachable:", err);
|
||||
setServerConfig(updatedConfig);
|
||||
setLockedServerChecking(false);
|
||||
setLockedServerUnreachable(true);
|
||||
@@ -392,20 +391,11 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
)}
|
||||
|
||||
{!lockConnectionMode && activeStep === SetupStep.SaaSSignup && (
|
||||
<SaaSSignupScreen
|
||||
loading={loading}
|
||||
error={error}
|
||||
onLogin={handleSaaSLogin}
|
||||
onSwitchToLogin={handleSwitchToLogin}
|
||||
/>
|
||||
<SaaSSignupScreen loading={loading} error={error} onLogin={handleSaaSLogin} onSwitchToLogin={handleSwitchToLogin} />
|
||||
)}
|
||||
|
||||
{!lockConnectionMode && activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelectionScreen
|
||||
onSelect={handleServerSelection}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<ServerSelectionScreen onSelect={handleServerSelection} loading={loading} error={error} />
|
||||
)}
|
||||
|
||||
{lockConnectionMode && lockedServerChecking && (
|
||||
@@ -415,28 +405,29 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
)}
|
||||
|
||||
{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')}>
|
||||
<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,
|
||||
})}
|
||||
{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 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')}
|
||||
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')}
|
||||
{t("setup.selfhosted.unreachable.changeServer", "Connect to a different server")}
|
||||
</DisabledButtonWithTooltip>
|
||||
) : (
|
||||
<Button
|
||||
@@ -449,16 +440,11 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}}
|
||||
>
|
||||
{t('setup.selfhosted.unreachable.changeServer', 'Connect to a different server')}
|
||||
{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 variant="subtle" color="white" fullWidth onClick={handleLocalMode}>
|
||||
{t("setup.selfhosted.unreachable.continueOffline", "Use local tools instead")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -466,7 +452,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
{activeStep === SetupStep.SelfHostedLogin && !lockedServerUnreachable && !lockedServerChecking && (
|
||||
<>
|
||||
<SelfHostedLoginScreen
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
serverUrl={serverConfig?.url || ""}
|
||||
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
||||
loginMethod={serverConfig?.loginMethod}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
@@ -477,14 +463,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
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')}
|
||||
<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>
|
||||
</>
|
||||
@@ -492,13 +473,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
|
||||
{/* 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')}
|
||||
<div className="navigation-link-container" style={{ marginTop: "1.5rem" }}>
|
||||
<button type="button" onClick={handleBack} className="navigation-link-button">
|
||||
{t("common.back", "Back")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,12 +483,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout =
|
||||
);
|
||||
|
||||
if (noLayout) {
|
||||
return <div style={{ padding: '2rem' }}>{wizardContent}</div>;
|
||||
return <div style={{ padding: "2rem" }}>{wizardContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopAuthLayout>
|
||||
{wizardContent}
|
||||
</DesktopAuthLayout>
|
||||
);
|
||||
return <DesktopAuthLayout>{wizardContent}</DesktopAuthLayout>;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
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);
|
||||
@@ -23,7 +23,9 @@ export function SignInModal() {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => { if (!locked) setOpened(false); }}
|
||||
onClose={() => {
|
||||
if (!locked) setOpened(false);
|
||||
}}
|
||||
size={520}
|
||||
centered
|
||||
withCloseButton={false}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import React from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
|
||||
interface FileEditorFileNameProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -15,47 +15,47 @@ const FileEditorFileName = ({ file }: FileEditorFileNameProps) => {
|
||||
<>
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
{!file.localFilePath && (
|
||||
<Tooltip label={t('fileNotSavedToDisk', 'Not saved to disk')}>
|
||||
<Tooltip label={t("fileNotSavedToDisk", "Not saved to disk")}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-red-6)',
|
||||
flexShrink: 0
|
||||
display: "inline-block",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--mantine-color-red-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={t('fileNotSavedToDisk', 'Not saved to disk')}
|
||||
aria-label={t("fileNotSavedToDisk", "Not saved to disk")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{file.localFilePath && file.isDirty && (
|
||||
<Tooltip label={t('unsavedChanges', 'Unsaved changes')}>
|
||||
<Tooltip label={t("unsavedChanges", "Unsaved changes")}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-yellow-6)',
|
||||
flexShrink: 0
|
||||
display: "inline-block",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--mantine-color-yellow-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={t('unsavedChanges', 'Unsaved changes')}
|
||||
aria-label={t("unsavedChanges", "Unsaved changes")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{file.localFilePath && !file.isDirty && (
|
||||
<Tooltip label={t('fileSavedToDisk', 'File saved to disk')}>
|
||||
<Tooltip label={t("fileSavedToDisk", "File saved to disk")}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-green-6)',
|
||||
flexShrink: 0
|
||||
display: "inline-block",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--mantine-color-green-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={t('fileSavedToDisk', 'File saved to disk')}
|
||||
aria-label={t("fileSavedToDisk", "File saved to disk")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* 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 { ONBOARDING_STEPS, getStepById, getStepIndex } from "@core/components/onboarding/orchestrator/onboardingConfig";
|
||||
|
||||
export type {
|
||||
OnboardingStepId,
|
||||
@@ -21,11 +17,11 @@ export type {
|
||||
OnboardingStep,
|
||||
OnboardingRuntimeState,
|
||||
OnboardingConditionContext,
|
||||
} from '@core/components/onboarding/orchestrator/onboardingConfig';
|
||||
} 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';
|
||||
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
|
||||
@@ -35,4 +31,3 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
...CORE_DEFAULT_RUNTIME_STATE,
|
||||
isDesktopApp: true,
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -8,14 +8,14 @@
|
||||
import {
|
||||
useOnboardingOrchestrator as useCoreOnboardingOrchestrator,
|
||||
type UseOnboardingOrchestratorResult,
|
||||
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||
import { DEFAULT_RUNTIME_STATE } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
} 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';
|
||||
} from "@core/components/onboarding/orchestrator/useOnboardingOrchestrator";
|
||||
|
||||
export function useOnboardingOrchestrator(): UseOnboardingOrchestratorResult {
|
||||
return useCoreOnboardingOrchestrator({ defaultRuntimeState: DEFAULT_RUNTIME_STATE });
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
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
|
||||
@@ -25,7 +25,7 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
|
||||
const checkMode = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === 'saas');
|
||||
setIsSaasMode(mode === "saas");
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === 'authenticated');
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
@@ -50,24 +50,32 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
|
||||
// - 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) {
|
||||
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' }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
|
||||
detail: { source: "quickAccessBar" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={className} style={{ padding: '0.5rem', cursor: 'pointer' }} onClick={handleClick}>
|
||||
<Box className={className} style={{ padding: "0.5rem", cursor: "pointer" }} onClick={handleClick}>
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{creditBalance} {creditBalance === 1 ? 'credit' : 'credits'}
|
||||
{creditBalance} {creditBalance === 1 ? "credit" : "credits"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ textDecoration: 'underline' }}>
|
||||
<Text size="xs" c="dimmed" style={{ textDecoration: "underline" }}>
|
||||
Upgrade
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Tooltip, rem, useComputedColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
|
||||
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
|
||||
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Box, Tooltip, rem, useComputedColorScheme } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connectionModeService, type ConnectionMode } from "@app/services/connectionModeService";
|
||||
import { selfHostedServerMonitor, type SelfHostedServerState } from "@app/services/selfHostedServerMonitor";
|
||||
import { useBackendHealth } from "@app/hooks/useBackendHealth";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
@@ -12,11 +12,9 @@ interface RightRailFooterExtensionsProps {
|
||||
|
||||
function ConnectionStatusDot() {
|
||||
const { t } = useTranslation();
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
|
||||
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(
|
||||
() => selfHostedServerMonitor.getSnapshot()
|
||||
);
|
||||
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(() => selfHostedServerMonitor.getSnapshot());
|
||||
const { isOnline, checkHealth } = useBackendHealth();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,31 +30,31 @@ function ConnectionStatusDot() {
|
||||
}, []);
|
||||
|
||||
const { label, color } = useMemo(() => {
|
||||
if (connectionMode === 'saas') {
|
||||
if (connectionMode === "saas") {
|
||||
return {
|
||||
label: t('connectionMode.status.saas', 'Connected to Stirling Cloud'),
|
||||
color: '#3b82f6',
|
||||
label: t("connectionMode.status.saas", "Connected to Stirling Cloud"),
|
||||
color: "#3b82f6",
|
||||
};
|
||||
}
|
||||
if (connectionMode === 'selfhosted') {
|
||||
if (connectionMode === "selfhosted") {
|
||||
const serverOnline = selfHostedState.isOnline;
|
||||
const serverChecking = selfHostedState.status === 'checking';
|
||||
const serverChecking = selfHostedState.status === "checking";
|
||||
const backendLabel = serverChecking
|
||||
? t('connectionMode.status.selfhostedChecking', 'Connected to self-hosted server (checking...)')
|
||||
? t("connectionMode.status.selfhostedChecking", "Connected to self-hosted server (checking...)")
|
||||
: serverOnline
|
||||
? t('connectionMode.status.selfhostedOnline', 'Connected to self-hosted server')
|
||||
: t('connectionMode.status.selfhostedOffline', 'Self-hosted server unreachable');
|
||||
? t("connectionMode.status.selfhostedOnline", "Connected to self-hosted server")
|
||||
: t("connectionMode.status.selfhostedOffline", "Self-hosted server unreachable");
|
||||
return {
|
||||
label: backendLabel,
|
||||
color: serverChecking ? '#fcc419' : serverOnline ? '#37b24d' : '#e03131',
|
||||
color: serverChecking ? "#fcc419" : serverOnline ? "#37b24d" : "#e03131",
|
||||
};
|
||||
}
|
||||
// local
|
||||
return {
|
||||
label: isOnline
|
||||
? t('connectionMode.status.localOnline', 'Offline mode running')
|
||||
: t('connectionMode.status.localOffline', 'Offline mode running'),
|
||||
color: '#868e96',
|
||||
? t("connectionMode.status.localOnline", "Offline mode running")
|
||||
: t("connectionMode.status.localOffline", "Offline mode running"),
|
||||
color: "#868e96",
|
||||
};
|
||||
}, [connectionMode, selfHostedState, isOnline, t]);
|
||||
|
||||
@@ -67,7 +65,7 @@ function ConnectionStatusDot() {
|
||||
offset={12}
|
||||
withArrow
|
||||
withinPortal
|
||||
color={colorScheme === 'dark' ? undefined : 'dark'}
|
||||
color={colorScheme === "dark" ? undefined : "dark"}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
@@ -75,7 +73,7 @@ function ConnectionStatusDot() {
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (connectionMode === 'local') {
|
||||
if (connectionMode === "local") {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
|
||||
} else {
|
||||
void checkHealth();
|
||||
@@ -84,14 +82,12 @@ function ConnectionStatusDot() {
|
||||
style={{
|
||||
width: rem(10),
|
||||
height: rem(10),
|
||||
borderRadius: '50%',
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
boxShadow: colorScheme === 'dark'
|
||||
? '0 0 0 2px rgba(255, 255, 255, 0.15)'
|
||||
: '0 0 0 2px rgba(0, 0, 0, 0.07)',
|
||||
display: 'inline-block',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
boxShadow: colorScheme === "dark" ? "0 0 0 2px rgba(255, 255, 255, 0.15)" : "0 0 0 2px rgba(0, 0, 0, 0.07)",
|
||||
display: "inline-block",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -103,10 +99,10 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
||||
<Box
|
||||
className={className}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingBottom: rem(12),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge, Tooltip } from '@mantine/core';
|
||||
import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CloudBadgeProps {
|
||||
className?: string;
|
||||
@@ -15,19 +15,14 @@ export function CloudBadge({ className }: CloudBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={t('cloudBadge.tooltip', 'This operation will use your cloud credits')}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<Tooltip label={t("cloudBadge.tooltip", "This operation will use your cloud credits")} position="top" withArrow>
|
||||
<Badge
|
||||
className={className}
|
||||
leftSection={<CloudOutlinedIcon sx={{ fontSize: 12 }} />}
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
>
|
||||
</Badge>
|
||||
></Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||
import { useDefaultApp } from '@app/hooks/useDefaultApp';
|
||||
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();
|
||||
@@ -15,8 +15,8 @@ export const DefaultAppBanner: React.FC = () => {
|
||||
return (
|
||||
<InfoBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
|
||||
buttonText={t('defaultApp.setDefault', 'Set Default')}
|
||||
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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import '@app/components/shared/DisabledButtonWithTooltip.css';
|
||||
import React from "react";
|
||||
import "@app/components/shared/DisabledButtonWithTooltip.css";
|
||||
|
||||
interface DisabledButtonWithTooltipProps {
|
||||
/** Tooltip text shown on hover */
|
||||
@@ -17,12 +17,8 @@ interface DisabledButtonWithTooltipProps {
|
||||
export function DisabledButtonWithTooltip({ tooltip, children, className, style }: DisabledButtonWithTooltipProps) {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className={`locked-button${className ? ` ${className}` : ''}`} style={style}>
|
||||
<div className="relative w-full" onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>
|
||||
<div className={`locked-button${className ? ` ${className}` : ""}`} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
{hovered && (
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
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';
|
||||
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)';
|
||||
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'],
|
||||
"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.
|
||||
@@ -42,24 +40,20 @@ const SPLIT_ENDPOINT_I18N: Record<string, [string, string]> = {
|
||||
export function SelfHostedOfflineBanner() {
|
||||
const { t } = useTranslation();
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
|
||||
const [serverState, setServerState] = useState<SelfHostedServerState>(
|
||||
() => selfHostedServerMonitor.getSnapshot()
|
||||
);
|
||||
const [serverState, setServerState] = useState<SelfHostedServerState>(() => selfHostedServerMonitor.getSnapshot());
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [localBackendReady, setLocalBackendReady] = useState(
|
||||
() => !!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
const [localBackendReady, setLocalBackendReady] = useState(() => !!tauriBackendService.getBackendUrl());
|
||||
|
||||
// Load connection mode and keep it live via subscription
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||
return connectionModeService.subscribeToModeChanges((config) => setConnectionMode(config.mode));
|
||||
}, []);
|
||||
|
||||
// Subscribe to self-hosted server status changes
|
||||
useEffect(() => {
|
||||
const unsub = selfHostedServerMonitor.subscribe(state => {
|
||||
const unsub = selfHostedServerMonitor.subscribe((state) => {
|
||||
setServerState(state);
|
||||
// Auto-collapse tool list when server comes back online
|
||||
if (state.isOnline) setExpanded(false);
|
||||
@@ -83,7 +77,7 @@ export function SelfHostedOfflineBanner() {
|
||||
|
||||
const [splitAvailability, setSplitAvailability] = useState<Record<string, boolean>>({});
|
||||
useEffect(() => {
|
||||
if (serverState.status !== 'offline') {
|
||||
if (serverState.status !== "offline") {
|
||||
setSplitAvailability({});
|
||||
return;
|
||||
}
|
||||
@@ -94,11 +88,11 @@ export function SelfHostedOfflineBanner() {
|
||||
}
|
||||
const uniqueEndpoints = [...new Set(Object.values(SPLIT_ENDPOINTS))] as string[];
|
||||
void Promise.all(
|
||||
uniqueEndpoints.map(async ep => ({
|
||||
uniqueEndpoints.map(async (ep) => ({
|
||||
ep,
|
||||
supported: await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl).catch(() => false),
|
||||
}))
|
||||
).then(results => {
|
||||
})),
|
||||
).then((results) => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const { ep, supported } of results) map[ep] = supported;
|
||||
setSplitAvailability(map);
|
||||
@@ -108,19 +102,19 @@ export function SelfHostedOfflineBanner() {
|
||||
const allUnavailableNames = useMemo(() => {
|
||||
// Top-level tools unavailable in self-hosted offline mode
|
||||
const toolNames = (Object.keys(toolAvailability) as ToolId[])
|
||||
.filter(id => toolAvailability[id]?.available === false && toolAvailability[id]?.reason === 'selfHostedOffline')
|
||||
.map(id => toolRegistry[id]?.name ?? id)
|
||||
.filter((id) => toolAvailability[id]?.available === false && toolAvailability[id]?.reason === "selfHostedOffline")
|
||||
.map((id) => toolRegistry[id]?.name ?? id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// 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';
|
||||
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 dashIdx = key.indexOf("-");
|
||||
const fromExt = key.slice(0, dashIdx);
|
||||
const toExt = key.slice(dashIdx + 1);
|
||||
const endpoint = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
@@ -128,11 +122,9 @@ export function SelfHostedOfflineBanner() {
|
||||
}
|
||||
}
|
||||
const conversionNames = [...unavailableEndpoints]
|
||||
.map(ep => {
|
||||
.map((ep) => {
|
||||
const i18n = ENDPOINT_I18N[ep];
|
||||
const suffix = i18n
|
||||
? (i18n[0] ? t(i18n[0], i18n[1]) : i18n[1])
|
||||
: ep;
|
||||
const suffix = i18n ? (i18n[0] ? t(i18n[0], i18n[1]) : i18n[1]) : ep;
|
||||
return `${convertPrefix}: ${suffix}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
@@ -151,16 +143,13 @@ export function SelfHostedOfflineBanner() {
|
||||
}, [toolAvailability, toolRegistry, conversionAvailability, splitAvailability, t]);
|
||||
|
||||
// Only show when in self-hosted mode, server confirmed offline, and not dismissed
|
||||
const show =
|
||||
!dismissed &&
|
||||
connectionMode === 'selfhosted' &&
|
||||
serverState.status === 'offline';
|
||||
const show = !dismissed && connectionMode === "selfhosted" && serverState.status === "offline";
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const messageText = localBackendReady
|
||||
? t('selfHosted.offline.messageWithFallback', 'Some tools require a server connection.')
|
||||
: t('selfHosted.offline.messageNoFallback', 'Tools are unavailable until your server comes back online.');
|
||||
? t("selfHosted.offline.messageWithFallback", "Some tools require a server connection.")
|
||||
: t("selfHosted.offline.messageNoFallback", "Tools are unavailable until your server comes back online.");
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -172,16 +161,14 @@ export function SelfHostedOfflineBanner() {
|
||||
>
|
||||
<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 }}
|
||||
/>
|
||||
<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')}
|
||||
{t("selfHosted.offline.title", "Server unreachable")}
|
||||
</Text>
|
||||
<Text size="xs" style={{ color: BANNER_TEXT, opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<Text
|
||||
size="xs"
|
||||
style={{ color: BANNER_TEXT, opacity: 0.8, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{messageText}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -196,18 +183,24 @@ export function SelfHostedOfflineBanner() {
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{ color: BANNER_LINK, fontSize: 'var(--mantine-font-size-xs)', fontWeight: 500, flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
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 ▾')}
|
||||
? 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 => (
|
||||
{allUnavailableNames.map((name) => (
|
||||
<List.Item key={name}>{name}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
@@ -219,7 +212,7 @@ export function SelfHostedOfflineBanner() {
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('close', 'Close')}
|
||||
aria-label={t("close", "Close")}
|
||||
style={{ color: BANNER_TEXT }}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="0.8rem" height="0.8rem" />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
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();
|
||||
@@ -18,7 +18,7 @@ export function TeamInvitationBanner() {
|
||||
|
||||
// Load connection mode on mount
|
||||
useEffect(() => {
|
||||
connectionModeService.getCurrentMode().then(mode => setConnectionMode(mode));
|
||||
connectionModeService.getCurrentMode().then((mode) => setConnectionMode(mode));
|
||||
}, []);
|
||||
|
||||
// Accept invitation handler
|
||||
@@ -30,18 +30,18 @@ export function TeamInvitationBanner() {
|
||||
|
||||
try {
|
||||
await acceptInvitation(invitation.invitationToken);
|
||||
console.log('[TeamInvitationBanner] Invitation accepted successfully:', invitation.teamName);
|
||||
console.log("[TeamInvitationBanner] Invitation accepted successfully:", invitation.teamName);
|
||||
|
||||
// Wait briefly for backend to process team membership update
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Refresh billing after joining team (tier may have changed)
|
||||
console.log('[TeamInvitationBanner] Refreshing billing after team join...');
|
||||
console.log("[TeamInvitationBanner] Refreshing billing after team join...");
|
||||
await refreshBilling();
|
||||
|
||||
setDismissed(true);
|
||||
} catch (error) {
|
||||
console.error('[TeamInvitationBanner] Failed to accept invitation:', error);
|
||||
console.error("[TeamInvitationBanner] Failed to accept invitation:", error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@@ -56,28 +56,25 @@ export function TeamInvitationBanner() {
|
||||
|
||||
try {
|
||||
await rejectInvitation(invitation.invitationToken);
|
||||
console.log('[TeamInvitationBanner] Invitation rejected');
|
||||
console.log("[TeamInvitationBanner] Invitation rejected");
|
||||
setDismissed(true);
|
||||
} catch (error) {
|
||||
console.error('[TeamInvitationBanner] Failed to reject invitation:', error);
|
||||
console.error("[TeamInvitationBanner] Failed to reject invitation:", error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Visibility logic
|
||||
const shouldShow =
|
||||
connectionMode === 'saas' &&
|
||||
!dismissed &&
|
||||
receivedInvitations.length > 0;
|
||||
const shouldShow = connectionMode === "saas" && !dismissed && receivedInvitations.length > 0;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const invitation = receivedInvitations[0]; // Show first invitation
|
||||
|
||||
const message = (
|
||||
<Text component="span" size="sm" fw={500} style={{ color: 'rgba(255, 255, 255, 0.95)' }}>
|
||||
<strong>{invitation.inviterEmail}</strong> {t('team.invitationBanner.message', 'has invited you to join')}{' '}
|
||||
<Text component="span" size="sm" fw={500} style={{ color: "rgba(255, 255, 255, 0.95)" }}>
|
||||
<strong>{invitation.inviterEmail}</strong> {t("team.invitationBanner.message", "has invited you to join")}{" "}
|
||||
<strong>{invitation.teamName}</strong>
|
||||
</Text>
|
||||
);
|
||||
@@ -90,23 +87,25 @@ export function TeamInvitationBanner() {
|
||||
size="xs"
|
||||
onClick={handleAccept}
|
||||
loading={processing}
|
||||
leftSection={<LocalIcon icon="check" width="0.9rem" height="0.9rem" style={{ color: 'var(--mantine-color-dark-9)' }} />}
|
||||
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)',
|
||||
color: "var(--mantine-color-dark-9)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('team.invitationBanner.acceptButton', 'Accept')}
|
||||
{t("team.invitationBanner.acceptButton", "Accept")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={handleReject}
|
||||
loading={processing}
|
||||
style={{ color: 'rgba(255, 255, 255, 0.7)' }}
|
||||
style={{ color: "rgba(255, 255, 255, 0.7)" }}
|
||||
>
|
||||
{t('team.invitationBanner.rejectButton', 'Decline')}
|
||||
{t("team.invitationBanner.rejectButton", "Decline")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
@@ -115,7 +114,7 @@ export function TeamInvitationBanner() {
|
||||
<InfoBanner
|
||||
icon="mail"
|
||||
message={
|
||||
<Group justify="space-between" align="center" wrap="nowrap" style={{ width: '100%' }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
{message}
|
||||
{actionButtons}
|
||||
</Group>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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';
|
||||
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';
|
||||
status: "idle" | "loading" | "opened" | "refreshing" | "error";
|
||||
error?: string;
|
||||
sessionPlanId?: string;
|
||||
};
|
||||
@@ -18,53 +18,45 @@ interface SaaSStripeCheckoutProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planId,
|
||||
onSuccess
|
||||
}) => {
|
||||
export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({ opened, onClose, planId, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
|
||||
const [state, setState] = useState<CheckoutState>({ status: "idle" });
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
if (!planId) {
|
||||
setState({ status: 'error', error: 'No plan selected' });
|
||||
setState({ status: "error", error: "No plan selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState({ status: 'loading' });
|
||||
setState({ status: "loading" });
|
||||
|
||||
// Map UI plan IDs to Stripe plan IDs
|
||||
const stripePlanId = planId === 'team' ? 'pro' : planId;
|
||||
const stripePlanId = planId === "team" ? "pro" : planId;
|
||||
|
||||
// Open checkout in browser (returns void, opens browser window)
|
||||
await saasBillingService.openCheckout(
|
||||
stripePlanId as 'pro',
|
||||
window.location.origin
|
||||
);
|
||||
await saasBillingService.openCheckout(stripePlanId as "pro", window.location.origin);
|
||||
|
||||
setState({
|
||||
status: 'opened',
|
||||
sessionPlanId: planId
|
||||
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);
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
|
||||
console.error("[SaaSStripeCheckout] Error creating checkout:", err);
|
||||
setState({
|
||||
status: 'error',
|
||||
error: errorMessage
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshClick = async () => {
|
||||
console.log('[SaaSStripeCheckout] User requested refresh after checkout');
|
||||
setState({ ...state, status: 'refreshing' });
|
||||
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));
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Trigger the refresh
|
||||
if (onSuccess) {
|
||||
@@ -77,7 +69,7 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state to idle to clean up the session
|
||||
setState({ status: 'idle', error: undefined, sessionPlanId: undefined });
|
||||
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -85,66 +77,57 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
// Check if we need a new session (first time or plan changed)
|
||||
const needsNewSession =
|
||||
state.status === 'idle' ||
|
||||
!state.sessionPlanId ||
|
||||
state.sessionPlanId !== planId;
|
||||
const needsNewSession = state.status === "idle" || !state.sessionPlanId || state.sessionPlanId !== planId;
|
||||
|
||||
if (needsNewSession) {
|
||||
console.log('[SaaSStripeCheckout] Opening checkout in browser for plan:', planId);
|
||||
console.log("[SaaSStripeCheckout] Opening checkout in browser for plan:", planId);
|
||||
createCheckoutSession();
|
||||
}
|
||||
} else if (!opened) {
|
||||
// Clean up state when modal closes
|
||||
setState({ status: 'idle', error: undefined, sessionPlanId: undefined });
|
||||
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
|
||||
}
|
||||
}, [opened, planId]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (state.status) {
|
||||
case 'loading':
|
||||
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...')}
|
||||
{t("payment.preparing", "Preparing your checkout...")}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'opened':
|
||||
case "opened":
|
||||
return (
|
||||
<Alert color="blue" title={t('payment.checkoutOpened', 'Checkout Opened in Browser')} icon={<OpenInBrowserIcon />}>
|
||||
<Alert color="blue" title={t("payment.checkoutOpened", "Checkout Opened in Browser")} icon={<OpenInBrowserIcon />}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t('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.')}
|
||||
{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 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 variant="subtle" onClick={handleClose} fullWidth>
|
||||
{t("payment.closeLater", "I'll Do This Later")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
case "error":
|
||||
return (
|
||||
<Alert color="red" title={t('payment.error', 'Payment Error')}>
|
||||
<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')}
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -156,9 +139,9 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
|
||||
};
|
||||
|
||||
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');
|
||||
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 (
|
||||
@@ -168,7 +151,7 @@ export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
|
||||
title={
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: getPlanName() })}
|
||||
{t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName: getPlanName() })}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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';
|
||||
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';
|
||||
export type { ConfigNavSection, ConfigNavItem } from "@core/components/shared/config/configNavSections";
|
||||
|
||||
/**
|
||||
* Hook version of desktop config nav sections with proper i18n support
|
||||
@@ -16,7 +19,7 @@ export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/co
|
||||
export const useConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false
|
||||
loginEnabled: boolean = false,
|
||||
): ConfigNavSection[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -25,30 +28,30 @@ export const useConfigNavSections = (
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||
return connectionModeService.subscribeToModeChanges((config) => setConnectionMode(config.mode));
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === 'authenticated');
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const isSaasMode = connectionMode === 'saas';
|
||||
const isLocalMode = connectionMode === 'local';
|
||||
const isSaasMode = connectionMode === "saas";
|
||||
const isLocalMode = connectionMode === "local";
|
||||
|
||||
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
|
||||
const connectionModeSection: ConfigNavSection = {
|
||||
title: t('settings.connection.title', 'Connection Mode'),
|
||||
title: t("settings.connection.title", "Connection Mode"),
|
||||
items: [
|
||||
{
|
||||
key: 'connectionMode',
|
||||
label: t('settings.connection.title', 'Connection Mode'),
|
||||
icon: 'desktop-cloud-rounded',
|
||||
key: "connectionMode",
|
||||
label: t("settings.connection.title", "Connection Mode"),
|
||||
icon: "desktop-cloud-rounded",
|
||||
component: <ConnectionSettings />,
|
||||
},
|
||||
],
|
||||
@@ -66,11 +69,11 @@ export const useConfigNavSections = (
|
||||
// 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
|
||||
"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).
|
||||
@@ -85,23 +88,23 @@ export const useConfigNavSections = (
|
||||
// Plan & Billing and Team sections only when authenticated in SaaS mode
|
||||
if (isSaasMode && isAuthenticated) {
|
||||
result.push({
|
||||
title: t('settings.planBilling.title', 'Plan & Billing'),
|
||||
title: t("settings.planBilling.title", "Plan & Billing"),
|
||||
items: [
|
||||
{
|
||||
key: 'planBilling',
|
||||
label: t('settings.planBilling.title', 'Plan & Billing'),
|
||||
icon: 'credit-card',
|
||||
key: "planBilling",
|
||||
label: t("settings.planBilling.title", "Plan & Billing"),
|
||||
icon: "credit-card",
|
||||
component: <SaasPlanSection />,
|
||||
},
|
||||
],
|
||||
});
|
||||
result.push({
|
||||
title: t('settings.team.title', 'Team'),
|
||||
title: t("settings.team.title", "Team"),
|
||||
items: [
|
||||
{
|
||||
key: 'teams',
|
||||
label: t('settings.team.title', 'Team'),
|
||||
icon: 'groups-rounded',
|
||||
key: "teams",
|
||||
label: t("settings.team.title", "Team"),
|
||||
icon: "groups-rounded",
|
||||
component: <SaaSTeamsSection />,
|
||||
},
|
||||
],
|
||||
@@ -115,9 +118,7 @@ export const useConfigNavSections = (
|
||||
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
|
||||
continue;
|
||||
}
|
||||
const filteredItems = isAuthenticated
|
||||
? section.items
|
||||
: section.items.filter(item => item.key !== 'account');
|
||||
const filteredItems = isAuthenticated ? section.items : section.items.filter((item) => item.key !== "account");
|
||||
if (filteredItems.length === 0) continue;
|
||||
result.push({ ...section, items: filteredItems });
|
||||
}
|
||||
@@ -132,21 +133,21 @@ export const useConfigNavSections = (
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false
|
||||
loginEnabled: boolean = false,
|
||||
): ConfigNavSection[] => {
|
||||
console.warn('createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.');
|
||||
console.warn("createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.");
|
||||
|
||||
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||
const sections = createProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
|
||||
// Add Connection section at the beginning (after Preferences)
|
||||
sections.splice(1, 0, {
|
||||
title: 'Connection',
|
||||
title: "Connection",
|
||||
items: [
|
||||
{
|
||||
key: 'connectionMode',
|
||||
label: 'Connection Mode',
|
||||
icon: 'desktop-cloud-rounded',
|
||||
key: "connectionMode",
|
||||
label: "Connection Mode",
|
||||
icon: "desktop-cloud-rounded",
|
||||
component: <ConnectionSettings />,
|
||||
},
|
||||
],
|
||||
@@ -154,12 +155,12 @@ export const createConfigNavSections = (
|
||||
|
||||
// Add Plan & Billing section (after Connection Mode)
|
||||
sections.splice(2, 0, {
|
||||
title: 'Plan & Billing',
|
||||
title: "Plan & Billing",
|
||||
items: [
|
||||
{
|
||||
key: 'planBilling',
|
||||
label: 'Plan & Billing',
|
||||
icon: 'credit-card',
|
||||
key: "planBilling",
|
||||
label: "Plan & Billing",
|
||||
icon: "credit-card",
|
||||
component: <SaasPlanSection />,
|
||||
},
|
||||
],
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Paper, Text, Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDefaultApp } from '@app/hooks/useDefaultApp';
|
||||
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();
|
||||
@@ -12,18 +12,18 @@ export const DefaultAppSettings: React.FC = () => {
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
|
||||
{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')
|
||||
? 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...')}
|
||||
? t("settings.general.defaultPdfEditorInactive", "Another application is set as default")
|
||||
: t("settings.general.defaultPdfEditorChecking", "Checking...")}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant={isDefault ? 'light' : 'filled'}
|
||||
variant={isDefault ? "light" : "filled"}
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={handleSetDefault}
|
||||
@@ -31,8 +31,8 @@ export const DefaultAppSettings: React.FC = () => {
|
||||
disabled={isDefault === true}
|
||||
>
|
||||
{isDefault
|
||||
? t('settings.general.defaultPdfEditorSet', 'Already Default')
|
||||
: t('settings.general.setAsDefault', 'Set as Default')}
|
||||
? t("settings.general.defaultPdfEditorSet", "Already Default")
|
||||
: t("settings.general.setAsDefault", "Set as Default")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
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
|
||||
|
||||
+138
-100
@@ -1,11 +1,27 @@
|
||||
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';
|
||||
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
|
||||
@@ -29,9 +45,9 @@ export function SaaSTeamsSection() {
|
||||
|
||||
// Check Pro status via billing context
|
||||
const { tier } = useSaaSBilling();
|
||||
const isPro = tier !== 'free';
|
||||
const isPro = tier !== "free";
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
@@ -39,7 +55,7 @@ export function SaaSTeamsSection() {
|
||||
|
||||
// Team rename state
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [newTeamName, setNewTeamName] = useState("");
|
||||
const [renamingTeam, setRenamingTeam] = useState(false);
|
||||
|
||||
// Refresh team data on mount and every 10 seconds
|
||||
@@ -56,7 +72,7 @@ export function SaaSTeamsSection() {
|
||||
}, []); // Only run on mount/unmount
|
||||
|
||||
const navigateToPlan = () => {
|
||||
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'planBilling' } }));
|
||||
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }));
|
||||
};
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
@@ -69,37 +85,37 @@ export function SaaSTeamsSection() {
|
||||
|
||||
try {
|
||||
await inviteUser(inviteEmail);
|
||||
setSuccess(t('team.inviteSent', 'Invitation sent to {{email}}', { email: inviteEmail }));
|
||||
setInviteEmail('');
|
||||
setSuccess(t("team.inviteSent", "Invitation sent to {{email}}", { email: inviteEmail }));
|
||||
setInviteEmail("");
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error.response?.data?.error || t('team.inviteError', 'Failed to send invitation'));
|
||||
setError(error.response?.data?.error || t("team.inviteError", "Failed to send invitation"));
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (memberId: number, memberEmail: string) => {
|
||||
if (!window.confirm(t('team.confirmRemove', 'Remove {{email}} from the team?', { email: memberEmail }))) return;
|
||||
if (!window.confirm(t("team.confirmRemove", "Remove {{email}} from the team?", { email: memberEmail }))) return;
|
||||
|
||||
try {
|
||||
await removeMember(memberId);
|
||||
setSuccess(t('team.memberRemoved', 'Member removed successfully'));
|
||||
setSuccess(t("team.memberRemoved", "Member removed successfully"));
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error.response?.data?.error || t('team.removeError', 'Failed to remove member'));
|
||||
setError(error.response?.data?.error || t("team.removeError", "Failed to remove member"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelInvitation = async (invitationId: number, email: string) => {
|
||||
if (!window.confirm(t('team.confirmCancelInvite', 'Cancel invitation for {{email}}?', { email }))) return;
|
||||
if (!window.confirm(t("team.confirmCancelInvite", "Cancel invitation for {{email}}?", { email }))) return;
|
||||
|
||||
try {
|
||||
await cancelInvitation(invitationId);
|
||||
setSuccess(t('team.inviteCancelled', 'Invitation for {{email}} cancelled', { email }));
|
||||
setSuccess(t("team.inviteCancelled", "Invitation for {{email}} cancelled", { email }));
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error.response?.data?.error || t('team.cancelInviteError', 'Failed to cancel invitation'));
|
||||
setError(error.response?.data?.error || t("team.cancelInviteError", "Failed to cancel invitation"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,7 +128,7 @@ export function SaaSTeamsSection() {
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setIsEditingName(false);
|
||||
setNewTeamName('');
|
||||
setNewTeamName("");
|
||||
};
|
||||
|
||||
const handleRenameSubmit = async () => {
|
||||
@@ -126,12 +142,12 @@ export function SaaSTeamsSection() {
|
||||
newName: newTeamName.trim(),
|
||||
});
|
||||
|
||||
setSuccess(t('team.renameSuccess', 'Team renamed successfully'));
|
||||
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'));
|
||||
setError(error.response?.data?.error || error.message || t("team.renameError", "Failed to rename team"));
|
||||
} finally {
|
||||
setRenamingTeam(false);
|
||||
}
|
||||
@@ -141,24 +157,28 @@ export function SaaSTeamsSection() {
|
||||
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 });
|
||||
? 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'));
|
||||
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'));
|
||||
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>
|
||||
<Text>{t("team.loading", "Loading team information...")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -174,12 +194,12 @@ export function SaaSTeamsSection() {
|
||||
<TextInput
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
placeholder={t('team.namePlaceholder', 'Team name')}
|
||||
placeholder={t("team.namePlaceholder", "Team name")}
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameSubmit();
|
||||
if (e.key === 'Escape') handleCancelRename();
|
||||
if (e.key === "Enter") handleRenameSubmit();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
@@ -191,37 +211,36 @@ export function SaaSTeamsSection() {
|
||||
>
|
||||
<LocalIcon icon="check" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleCancelRename}
|
||||
disabled={renamingTeam}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={handleCancelRename} disabled={renamingTeam}>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="lg">{currentTeam.name}</Text>
|
||||
<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')}
|
||||
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>}
|
||||
{isTeamLeader && <Badge color="blue">{t("team.leader", "LEADER")}</Badge>}
|
||||
{isPersonalTeam && (
|
||||
<Badge color="gray" variant="light" size="xs">{t('team.personal', 'Personal')}</Badge>
|
||||
<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 })}
|
||||
{t("team.memberCount", "{{count}} team members", { count: currentTeam.seatsUsed })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,7 +252,7 @@ export function SaaSTeamsSection() {
|
||||
onClick={handleLeaveTeam}
|
||||
leftSection={<LocalIcon icon="logout" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('team.leaveButton', 'Leave Team')}
|
||||
{t("team.leaveButton", "Leave Team")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -244,20 +263,18 @@ export function SaaSTeamsSection() {
|
||||
<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 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')}
|
||||
{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 size="sm" variant="light" onClick={navigateToPlan}>
|
||||
{t("team.upgrade.button", "Upgrade to Pro")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
@@ -273,26 +290,28 @@ export function SaaSTeamsSection() {
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setFeaturesModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
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>
|
||||
<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')}
|
||||
{t("team.features.title", "Team Collaboration")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('team.features.subtitle', 'Upgrade to Pro and unlock powerful team features')}
|
||||
{t("team.features.subtitle", "Upgrade to Pro and unlock powerful team features")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -307,20 +326,28 @@ export function SaaSTeamsSection() {
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -333,7 +360,7 @@ export function SaaSTeamsSection() {
|
||||
navigateToPlan();
|
||||
}}
|
||||
>
|
||||
{t('team.features.viewPlans', 'View Pro Plans')}
|
||||
{t("team.features.viewPlans", "View Pro Plans")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
@@ -355,24 +382,30 @@ export function SaaSTeamsSection() {
|
||||
{/* Invite Members (Pro Users) */}
|
||||
{isTeamLeader && isPro && (
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">{t('team.invite.title', 'Invite Team Member')}</Text>
|
||||
<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]')}
|
||||
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}
|
||||
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')}
|
||||
{t("team.invite.sendButton", "Send Invite")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
@@ -381,30 +414,32 @@ export function SaaSTeamsSection() {
|
||||
|
||||
{/* Team Members Table */}
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">{t('team.members.title', 'Team Members')}</Text>
|
||||
<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}
|
||||
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.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 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 style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--mantine-color-gray-7)" }}>
|
||||
{t("team.members.roleColumn", "Role")}
|
||||
</Table.Th>
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
)}
|
||||
{isTeamLeader && !isPersonalTeam && <Table.Th style={{ width: 50 }}></Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -412,7 +447,7 @@ export function SaaSTeamsSection() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('team.members.empty', 'No team members yet.')}
|
||||
{t("team.members.empty", "No team members yet.")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -434,18 +469,22 @@ export function SaaSTeamsSection() {
|
||||
<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}
|
||||
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' && (
|
||||
{member.role !== "LEADER" && (
|
||||
<Menu position="bottom-end" withinPortal zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
@@ -458,7 +497,7 @@ export function SaaSTeamsSection() {
|
||||
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
|
||||
onClick={() => handleRemove(member.id, member.email)}
|
||||
>
|
||||
{t('team.members.remove', 'Remove from Team')}
|
||||
{t("team.members.remove", "Remove from Team")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
@@ -470,12 +509,12 @@ export function SaaSTeamsSection() {
|
||||
|
||||
{/* Pending Invitations */}
|
||||
{teamInvitations
|
||||
.filter(inv => inv.status === 'PENDING')
|
||||
.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]}
|
||||
{invitation.inviteeEmail.split("@")[0]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -485,7 +524,7 @@ export function SaaSTeamsSection() {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color="yellow" variant="light">
|
||||
{t('team.members.pending', 'PENDING')}
|
||||
{t("team.members.pending", "PENDING")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
@@ -494,7 +533,7 @@ export function SaaSTeamsSection() {
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => handleCancelInvitation(invitation.invitationId, invitation.inviteeEmail)}
|
||||
aria-label={t('team.invite.cancelLabel', 'Cancel invitation')}
|
||||
aria-label={t("team.invite.cancelLabel", "Cancel invitation")}
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
@@ -507,7 +546,6 @@ export function SaaSTeamsSection() {
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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/ErrorOutline';
|
||||
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';
|
||||
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/ErrorOutline";
|
||||
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
|
||||
@@ -48,14 +48,14 @@ export function SaasPlanSection() {
|
||||
useEffect(() => {
|
||||
const checkMode = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
setIsSaasMode(mode === 'saas');
|
||||
setIsSaasMode(mode === "saas");
|
||||
};
|
||||
|
||||
checkMode();
|
||||
|
||||
// Subscribe to mode changes
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(async (config) => {
|
||||
setIsSaasMode(config.mode === 'saas');
|
||||
setIsSaasMode(config.mode === "saas");
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
@@ -69,7 +69,7 @@ export function SaasPlanSection() {
|
||||
// Context handles opening portal and auto-refresh
|
||||
await openBillingPortal();
|
||||
} catch (error) {
|
||||
console.error('[SaasPlanSection] Failed to open billing portal:', error);
|
||||
console.error("[SaasPlanSection] Failed to open billing portal:", error);
|
||||
} finally {
|
||||
setIsOpeningPortal(false);
|
||||
}
|
||||
@@ -78,9 +78,9 @@ export function SaasPlanSection() {
|
||||
// Format date for trial end
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -91,8 +91,8 @@ export function SaasPlanSection() {
|
||||
<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).'
|
||||
"settings.planBilling.notAvailable",
|
||||
"Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
@@ -117,7 +117,7 @@ export function SaasPlanSection() {
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="md" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.planBilling.loading', 'Loading billing information...')}
|
||||
{t("settings.planBilling.loading", "Loading billing information...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -132,17 +132,12 @@ export function SaasPlanSection() {
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
|
||||
title={t('settings.planBilling.errors.fetchFailed', 'Unable to fetch billing data')}
|
||||
title={t("settings.planBilling.errors.fetchFailed", "Unable to fetch billing data")}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{error}</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={refreshBilling}
|
||||
size="xs"
|
||||
>
|
||||
{t('settings.planBilling.errors.retry', 'Retry')}
|
||||
<Button variant="light" leftSection={<RefreshIcon sx={{ fontSize: 16 }} />} onClick={refreshBilling} size="xs">
|
||||
{t("settings.planBilling.errors.retry", "Retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -156,10 +151,10 @@ export function SaasPlanSection() {
|
||||
<div>
|
||||
{/* Header with title and Manage Billing button */}
|
||||
<Flex justify="space-between" align="center" mb="md">
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('settings.planBilling.currentPlan', 'Active Plan')}
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
{t("settings.planBilling.currentPlan", "Active Plan")}
|
||||
</h3>
|
||||
{tier !== 'free' && !isManagedTeamMember && (
|
||||
{tier !== "free" && !isManagedTeamMember && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
@@ -167,7 +162,7 @@ export function SaasPlanSection() {
|
||||
loading={isOpeningPortal}
|
||||
disabled={isOpeningPortal}
|
||||
>
|
||||
{t('settings.planBilling.billing.manageBilling', 'Manage Billing')}
|
||||
{t("settings.planBilling.billing.manageBilling", "Manage Billing")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -179,16 +174,16 @@ export function SaasPlanSection() {
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
|
||||
mt="md"
|
||||
mb="md"
|
||||
title={t('settings.planBilling.trial.title', 'Free Trial Active')}
|
||||
title={t("settings.planBilling.trial.title", "Free Trial Active")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('settings.planBilling.trial.daysRemainingFull', 'Your trial ends in {{days}} days', {
|
||||
{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}}', {
|
||||
{t("settings.planBilling.trial.endDate", "Expires: {{date}}", {
|
||||
date: formatDate(subscription.currentPeriodEnd),
|
||||
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
|
||||
})}
|
||||
@@ -212,12 +207,7 @@ export function SaasPlanSection() {
|
||||
/>
|
||||
|
||||
{/* Available plans grid */}
|
||||
<SaaSAvailablePlansSection
|
||||
plans={plans}
|
||||
currentTier={tier}
|
||||
loading={plansLoading}
|
||||
error={plansError}
|
||||
/>
|
||||
<SaaSAvailablePlansSection plans={plans} currentTier={tier} loading={plansLoading} error={plansError} />
|
||||
</Stack>
|
||||
</div>
|
||||
</SaaSCheckoutProvider>
|
||||
|
||||
+44
-40
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
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;
|
||||
@@ -15,9 +15,9 @@ interface TeamData {
|
||||
}
|
||||
|
||||
interface ActiveSubscriptionCardProps {
|
||||
tier: BillingStatus['tier'];
|
||||
subscription: BillingStatus['subscription'];
|
||||
usage: BillingStatus['meterUsage'];
|
||||
tier: BillingStatus["tier"];
|
||||
subscription: BillingStatus["subscription"];
|
||||
usage: BillingStatus["meterUsage"];
|
||||
isTrialing: boolean;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
@@ -42,21 +42,21 @@ export function ActiveSubscriptionCard({
|
||||
// Format timestamp to readable date
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
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');
|
||||
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;
|
||||
}
|
||||
@@ -64,31 +64,31 @@ export function ActiveSubscriptionCard({
|
||||
|
||||
// Get price display
|
||||
const getPriceDisplay = (): string => {
|
||||
if (tier === 'free') {
|
||||
return '$0/month';
|
||||
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';
|
||||
return "$10/month";
|
||||
};
|
||||
|
||||
// Get description
|
||||
const getDescription = (): string => {
|
||||
if (tier === 'free') {
|
||||
return t('settings.planBilling.tier.freeDescription', '50 credits per month');
|
||||
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'
|
||||
"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', {
|
||||
return t("settings.planBilling.billing.overageCost", {
|
||||
amount: `$${(cents / 100).toFixed(2)}`,
|
||||
credits,
|
||||
defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`,
|
||||
@@ -96,7 +96,7 @@ export function ActiveSubscriptionCard({
|
||||
};
|
||||
|
||||
// Pro/Team card
|
||||
if (tier === 'team' || tier === 'enterprise') {
|
||||
if (tier === "team" || tier === "enterprise") {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
@@ -105,30 +105,33 @@ export function ActiveSubscriptionCard({
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<Text size="lg" fw={600}>
|
||||
{!isPersonalTeam && isTeamLeader ? t('settings.planBilling.tier.team', 'Team Plan') : getTierName()}
|
||||
{!isPersonalTeam && isTeamLeader ? t("settings.planBilling.tier.team", "Team Plan") : getTierName()}
|
||||
</Text>
|
||||
{!isPersonalTeam && (
|
||||
<Badge color="violet" variant="light" leftSection={<GroupIcon sx={{ fontSize: 12 }} />}>
|
||||
{t('settings.planBilling.tier.teamBadge', 'Team')}
|
||||
{t("settings.planBilling.tier.teamBadge", "Team")}
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip
|
||||
label={
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<Text size="sm" mb="xs">
|
||||
{t('settings.planBilling.tier.teamTooltipCredits', {
|
||||
{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', {
|
||||
{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.')}
|
||||
{t(
|
||||
"settings.planBilling.tier.teamTooltipFineprint",
|
||||
"Only pay for what you use beyond included credits.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
@@ -143,18 +146,18 @@ export function ActiveSubscriptionCard({
|
||||
</Tooltip>
|
||||
{isTrialing && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t('settings.planBilling.status.trial', 'Trial')}
|
||||
{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')}
|
||||
{t("settings.planBilling.team.managedByTeam", "Managed by team")}
|
||||
</Text>
|
||||
)}
|
||||
{!isPersonalTeam && isTeamLeader && currentTeam && (
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{t('settings.planBilling.team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })}
|
||||
{t("settings.planBilling.team.memberCount", "{{count}} team members", { count: currentTeam.seatsUsed })}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
@@ -169,10 +172,10 @@ export function ActiveSubscriptionCard({
|
||||
</div>
|
||||
|
||||
{/* Right side: Price */}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
{!isPersonalTeam && !isTeamLeader ? (
|
||||
<Text size="lg" c="dimmed">
|
||||
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
|
||||
{t("settings.planBilling.team.managedByTeam", "Managed by team")}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -186,7 +189,8 @@ export function ActiveSubscriptionCard({
|
||||
{subscription?.currentPeriodEnd && (
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.planBilling.billing.nextBillingDate', 'Next billing date:')} {formatDate(subscription.currentPeriodEnd)}
|
||||
{t("settings.planBilling.billing.nextBillingDate", "Next billing date:")}{" "}
|
||||
{formatDate(subscription.currentPeriodEnd)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
@@ -209,7 +213,7 @@ export function ActiveSubscriptionCard({
|
||||
{getDescription()}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Text size="xl" fw={700}>
|
||||
{getPriceDisplay()}
|
||||
</Text>
|
||||
|
||||
+19
-19
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
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;
|
||||
@@ -15,7 +15,7 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Don't show upgrade card if already on Team or Enterprise
|
||||
if (currentTier !== 'free') {
|
||||
if (currentTier !== "free") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
|
||||
try {
|
||||
await shellOpen(upgradeUrl);
|
||||
} catch (error) {
|
||||
console.error('[PlanUpgradeCard] Failed to open upgrade URL:', error);
|
||||
console.error("[PlanUpgradeCard] Failed to open upgrade URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,12 +35,12 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Text size="lg" fw={600}>
|
||||
{t('settings.planBilling.upgrade.title', 'Upgrade Your Plan')}
|
||||
{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:')}
|
||||
{t("settings.planBilling.upgrade.subtitle", "Upgrade to Team for:")}
|
||||
</Text>
|
||||
|
||||
<List
|
||||
@@ -53,25 +53,25 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
|
||||
}
|
||||
>
|
||||
<List.Item>
|
||||
{t('settings.planBilling.upgrade.featureCredits', {
|
||||
{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.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')}
|
||||
{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')}
|
||||
{t("settings.planBilling.upgrade.opensInBrowser", "Opens in browser to complete upgrade")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
+12
-14
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
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[];
|
||||
@@ -17,7 +17,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
|
||||
plans,
|
||||
currentTier,
|
||||
loading,
|
||||
error
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { openCheckout } = useSaaSCheckout();
|
||||
@@ -33,7 +33,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SaaSAvailablePlansSection] Upgrade clicked for plan:', plan.id);
|
||||
console.log("[SaaSAvailablePlansSection] Upgrade clicked for plan:", plan.id);
|
||||
openCheckout(plan.id);
|
||||
};
|
||||
|
||||
@@ -48,9 +48,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="orange" variant="light" mt="md">
|
||||
<Text size="sm">
|
||||
{t('plan.availablePlans.loadError', 'Unable to load plan pricing. Using default values.')}
|
||||
</Text>
|
||||
<Text size="sm">{t("plan.availablePlans.loadError", "Unable to load plan pricing. Using default values.")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -58,10 +56,10 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
|
||||
return (
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t('plan.availablePlans.title', 'Available Plans')}
|
||||
{t("plan.availablePlans.title", "Available Plans")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
|
||||
{plans.map(plan => (
|
||||
{plans.map((plan) => (
|
||||
<SaasPlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
|
||||
+57
-63
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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;
|
||||
@@ -12,30 +12,25 @@ interface SaasPlanCardProps {
|
||||
onUpgradeClick?: (plan: PlanTier) => void;
|
||||
}
|
||||
|
||||
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
|
||||
plan,
|
||||
isCurrentPlan,
|
||||
currentTier,
|
||||
onUpgradeClick
|
||||
}) => {
|
||||
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({ plan, isCurrentPlan, currentTier, onUpgradeClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Free plan is included if user has Team or Enterprise tier
|
||||
const isIncluded = plan.id === 'free' && (currentTier === 'team' || currentTier === 'enterprise');
|
||||
const isIncluded = plan.id === "free" && (currentTier === "team" || currentTier === "enterprise");
|
||||
|
||||
// Determine card styling based on plan type
|
||||
const getCardStyle = () => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
borderWidth: 1,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
position: "relative",
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
if (plan.id === 'free' && isCurrentPlan) {
|
||||
if (plan.id === "free" && isCurrentPlan) {
|
||||
return {
|
||||
...baseStyle,
|
||||
borderColor: 'var(--border-default)',
|
||||
borderColor: "var(--border-default)",
|
||||
opacity: 0.85,
|
||||
};
|
||||
}
|
||||
@@ -43,11 +38,11 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
|
||||
if (plan.popular) {
|
||||
return {
|
||||
...baseStyle,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
borderWidth: 2,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.1)',
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,15 +51,15 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
|
||||
|
||||
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)';
|
||||
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)';
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = "0 2px 8px rgba(59, 130, 246, 0.1)";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,29 +84,31 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
|
||||
<Badge
|
||||
size="sm"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: -10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgb(59, 130, 246)',
|
||||
color: 'white',
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "rgb(59, 130, 246)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "12px",
|
||||
}}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="sm" className="h-full">
|
||||
<div>
|
||||
<Text size="md" fw={600} mb="xs">{plan.name}</Text>
|
||||
<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}`}
|
||||
{plan.isContactOnly ? t("plan.customPricing", "Custom") : `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -121,30 +118,28 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
|
||||
</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')}`
|
||||
? 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')}`
|
||||
}
|
||||
? `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:')
|
||||
}
|
||||
{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)'}
|
||||
color={plan.id === "free" ? "var(--mantine-color-gray-6)" : "var(--color-primary-600)"}
|
||||
size="xs"
|
||||
>
|
||||
{highlight}
|
||||
@@ -168,26 +163,25 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
...((isCurrentPlan || isIncluded) && {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'default',
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "default",
|
||||
}),
|
||||
...(plan.isContactOnly && {
|
||||
borderColor: 'var(--text-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
borderColor: "var(--text-primary)",
|
||||
color: "var(--text-primary)",
|
||||
}),
|
||||
}}
|
||||
component={plan.isContactOnly ? 'a' : undefined}
|
||||
component={plan.isContactOnly ? "a" : undefined}
|
||||
href={plan.isContactOnly ? `mailto:[email protected]?subject=${plan.name} Plan Inquiry` : undefined}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t('plan.current', 'Current Plan')
|
||||
? t("plan.current", "Current Plan")
|
||||
: isIncluded
|
||||
? t('plan.included', 'Included')
|
||||
: plan.isContactOnly
|
||||
? t('plan.contact', 'Contact Sales')
|
||||
: t('plan.upgrade', 'Upgrade')
|
||||
}
|
||||
? t("plan.included", "Included")
|
||||
: plan.isContactOnly
|
||||
? t("plan.contact", "Contact Sales")
|
||||
: t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
+21
-28
@@ -1,13 +1,13 @@
|
||||
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';
|
||||
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'];
|
||||
tier: BillingStatus["tier"];
|
||||
usage: BillingStatus["meterUsage"];
|
||||
}
|
||||
|
||||
export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
@@ -16,11 +16,11 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
// Credits per month based on tier
|
||||
const getMonthlyCredits = (): number => {
|
||||
switch (tier) {
|
||||
case 'free':
|
||||
case "free":
|
||||
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
|
||||
case 'team':
|
||||
case "team":
|
||||
return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH;
|
||||
case 'enterprise':
|
||||
case "enterprise":
|
||||
return 1000; // Placeholder — enterprise credits are custom
|
||||
default:
|
||||
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
|
||||
@@ -39,13 +39,13 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Text size="lg" fw={600}>
|
||||
{t('settings.planBilling.credits.title', 'Credit Usage')}
|
||||
{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', {
|
||||
{t("settings.planBilling.credits.included", {
|
||||
count: monthlyCredits,
|
||||
defaultValue: `${monthlyCredits} credits/month (included)`,
|
||||
})}
|
||||
@@ -58,13 +58,13 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.planBilling.credits.overage', {
|
||||
{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', {
|
||||
{t("settings.planBilling.credits.estimatedCost", {
|
||||
amount: formatCurrency(usage.estimatedCost),
|
||||
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
|
||||
})}
|
||||
@@ -72,19 +72,12 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
</Group>
|
||||
|
||||
{/* Progress bar for overage usage */}
|
||||
<Progress
|
||||
value={100}
|
||||
color="orange"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
striped
|
||||
animated
|
||||
/>
|
||||
<Progress value={100} color="orange" size="sm" radius="xl" striped animated />
|
||||
</Stack>
|
||||
|
||||
<Alert color="blue" variant="light" icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}>
|
||||
<Text size="xs">
|
||||
{t('settings.planBilling.credits.overageInfo', {
|
||||
{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.`,
|
||||
})}
|
||||
@@ -94,10 +87,10 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
)}
|
||||
|
||||
{/* No overage message */}
|
||||
{(!usage || usage.currentPeriodCredits === 0) && tier !== 'free' && (
|
||||
{(!usage || usage.currentPeriodCredits === 0) && tier !== "free" && (
|
||||
<Alert color="green" variant="light">
|
||||
<Text size="sm">
|
||||
{t('settings.planBilling.credits.noOverage', {
|
||||
{t("settings.planBilling.credits.noOverage", {
|
||||
count: monthlyCredits,
|
||||
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
|
||||
})}
|
||||
@@ -106,10 +99,10 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
)}
|
||||
|
||||
{/* Free tier message */}
|
||||
{tier === 'free' && (
|
||||
{tier === "free" && (
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t('settings.planBilling.credits.freeTierInfo', {
|
||||
{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.`,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/types';
|
||||
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
|
||||
|
||||
export const VALID_NAV_KEYS = [
|
||||
...CORE_NAV_KEYS,
|
||||
'connectionMode',
|
||||
'planBilling',
|
||||
] as const;
|
||||
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "connectionMode", "planBilling"] as const;
|
||||
|
||||
export type NavKey = typeof VALID_NAV_KEYS[number];
|
||||
export type NavKey = (typeof VALID_NAV_KEYS)[number];
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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';
|
||||
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;
|
||||
@@ -30,7 +30,7 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
|
||||
refreshBilling,
|
||||
onClose,
|
||||
'CreditExhaustedModal'
|
||||
"CreditExhaustedModal",
|
||||
);
|
||||
|
||||
// Managed team members have unlimited credits via team
|
||||
@@ -43,14 +43,17 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
centered
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
title={t('credits.modal.managedMemberTitle', 'Unlimited Credits')}
|
||||
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.')}
|
||||
{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')}
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -59,10 +62,9 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
|
||||
// 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 ?? '$';
|
||||
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);
|
||||
|
||||
@@ -80,31 +82,28 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
title={
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={450}>
|
||||
{t('credits.modal.titleExhaustedPro', 'You have run out of credits')}
|
||||
{t("credits.modal.titleExhaustedPro", "You have run out of credits")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('credits.modal.subtitlePro', 'Enable automatic overage billing to never run out of credits.')}
|
||||
{t("credits.modal.subtitlePro", "Enable automatic overage billing to never run out of credits.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
styles={{
|
||||
body: { padding: '0rem 0rem 0.5rem 0rem' },
|
||||
body: { padding: "0rem 0rem 0.5rem 0rem" },
|
||||
content: {
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
header: {
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
overlay: {
|
||||
backgroundColor: 'light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))',
|
||||
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}
|
||||
/>
|
||||
<CreditUsageBanner currentCredits={creditBalance} totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} />
|
||||
|
||||
{meteringError && (
|
||||
<Alert color="red" ml="lg" mr="lg">
|
||||
@@ -120,21 +119,21 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
ml="lg"
|
||||
mr="lg"
|
||||
style={{
|
||||
borderColor: 'var(--color-primary-600)',
|
||||
borderColor: "var(--color-primary-600)",
|
||||
borderWidth: 2,
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group gap="xs" align="center">
|
||||
<TrendingUpIcon sx={{ fontSize: 24, color: 'var(--color-primary-600)' }} />
|
||||
<TrendingUpIcon sx={{ fontSize: 24, color: "var(--color-primary-600)" }} />
|
||||
<Text size="lg" fw={600}>
|
||||
{t('credits.modal.meteringTitle', 'Pay-What-You-Use Overage Billing')}
|
||||
{t("credits.modal.meteringTitle", "Pay-What-You-Use Overage Billing")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('credits.modal.meteringExplanation', {
|
||||
{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.`,
|
||||
})}
|
||||
@@ -142,24 +141,24 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
|
||||
<Stack gap="xs">
|
||||
<FeatureListItem included>
|
||||
{t('credits.modal.meteringIncluded', {
|
||||
{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', {
|
||||
{t("credits.modal.meteringPrice", "Additional credits at {{price}}/credit", {
|
||||
price: formattedOveragePrice,
|
||||
})}
|
||||
</FeatureListItem>
|
||||
<FeatureListItem included>
|
||||
{t('credits.modal.meteringPayAsYouGo', 'Only pay for what you use')}
|
||||
{t("credits.modal.meteringPayAsYouGo", "Only pay for what you use")}
|
||||
</FeatureListItem>
|
||||
<FeatureListItem included>
|
||||
{t('credits.modal.meteringNoCommitment', 'No commitment, cancel anytime')}
|
||||
{t("credits.modal.meteringNoCommitment", "No commitment, cancel anytime")}
|
||||
</FeatureListItem>
|
||||
<FeatureListItem included>
|
||||
{t('credits.modal.meteringNeverRunOut', 'Never run out of credits')}
|
||||
{t("credits.modal.meteringNeverRunOut", "Never run out of credits")}
|
||||
</FeatureListItem>
|
||||
</Stack>
|
||||
|
||||
@@ -167,12 +166,15 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
padding="md"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: 'light-dark(#F8F9FA, #1A1A1E)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
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.')}
|
||||
{t(
|
||||
"credits.modal.meteringBillingNote",
|
||||
"Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.",
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
@@ -193,15 +195,15 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t('credits.enableOverageBilling', 'Enable Overage Billing')}
|
||||
{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')}
|
||||
{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')}
|
||||
{t("credits.maybeLater", "Maybe later")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -210,9 +212,9 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
}
|
||||
|
||||
// Free tier users - show upgrade modal
|
||||
const teamPlan = plans.get('team');
|
||||
const teamPlan = plans.get("team");
|
||||
const teamPrice = teamPlan?.price ?? 20;
|
||||
const teamCurrency = teamPlan?.currency ?? '$';
|
||||
const teamCurrency = teamPlan?.currency ?? "$";
|
||||
const overagePrice = teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
||||
|
||||
const currencySymbol = getCurrencySymbol(teamCurrency);
|
||||
@@ -232,31 +234,28 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
title={
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={450}>
|
||||
{t('credits.modal.titleExhausted', "You've used your free credits")}
|
||||
{t("credits.modal.titleExhausted", "You've used your free credits")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('credits.modal.subtitle', 'Upgrade to Team for 10x the credits and faster processing.')}
|
||||
{t("credits.modal.subtitle", "Upgrade to Team for 10x the credits and faster processing.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
styles={{
|
||||
body: { padding: '0rem 0rem 0.5rem 0rem' },
|
||||
body: { padding: "0rem 0rem 0.5rem 0rem" },
|
||||
content: {
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
header: {
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
overlay: {
|
||||
backgroundColor: 'light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))',
|
||||
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}
|
||||
/>
|
||||
<CreditUsageBanner currentCredits={creditBalance} totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH} />
|
||||
|
||||
<Group gap="md" ml="lg" mr="lg" align="stretch" grow>
|
||||
{/* Free Plan Card */}
|
||||
@@ -265,33 +264,33 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: 'var(--border-default)',
|
||||
borderColor: "var(--border-default)",
|
||||
borderWidth: 1,
|
||||
opacity: 0.85,
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="xs">
|
||||
{t('credits.modal.freeTier', 'Free Tier')}
|
||||
{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')}
|
||||
{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')}
|
||||
{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:')}
|
||||
{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)">
|
||||
@@ -308,12 +307,12 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
radius="lg"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'default',
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
{t('credits.modal.current', 'Current Plan')}
|
||||
{t("credits.modal.current", "Current Plan")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -324,65 +323,66 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: 'var(--card-selected-border)',
|
||||
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)',
|
||||
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')}
|
||||
onClick={() => openCheckout("pro")}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 48px rgba(59, 130, 246, 0.3)';
|
||||
e.currentTarget.style.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)';
|
||||
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',
|
||||
position: "absolute",
|
||||
top: -10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgb(59, 130, 246)',
|
||||
color: 'white',
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "rgb(59, 130, 246)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "12px",
|
||||
}}
|
||||
>
|
||||
{t('credits.modal.popular', 'Popular')}
|
||||
{t("credits.modal.popular", "Popular")}
|
||||
</Badge>
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="xs">
|
||||
{t('credits.modal.teamSubscription', 'Team')}
|
||||
{t("credits.modal.teamSubscription", "Team")}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline" mt="xs">
|
||||
<Text size="1.75rem" fw={700} style={{ color: 'var(--text-primary)' }}>
|
||||
<Text size="1.75rem" fw={700} style={{ color: "var(--text-primary)" }}>
|
||||
{currencySymbol}
|
||||
{teamPrice}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('credits.modal.perMonth', '/month')}
|
||||
{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')}
|
||||
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} {t("credits.modal.monthlyCredits", "monthly credits")} +{" "}
|
||||
{formattedOveragePrice}/{t("credits.modal.overage", "overage")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t('credits.modal.everythingInFree', 'Everything in Free, plus:')}
|
||||
{t("credits.modal.everythingInFree", "Everything in Free, plus:")}
|
||||
</Text>
|
||||
{TEAM_PLAN_FEATURES.map((feature, index) => (
|
||||
<FeatureListItem key={index} included>
|
||||
@@ -392,7 +392,7 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
onClick={() => openCheckout('pro')}
|
||||
onClick={() => openCheckout("pro")}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
@@ -402,7 +402,7 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t('credits.upgrade', 'Upgrade')}
|
||||
{t("credits.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -414,25 +414,25 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
withBorder
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="md" fw={600} mb="xs">
|
||||
{t('credits.modal.enterpriseSubscription', 'Enterprise')}
|
||||
{t("credits.modal.enterpriseSubscription", "Enterprise")}
|
||||
</Text>
|
||||
<Text size="1.75rem" fw={600}>
|
||||
{t('credits.modal.customPricing', 'Custom')}
|
||||
{t("credits.modal.customPricing", "Custom")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t('credits.modal.unlimitedMonthlyCredits', 'Site License')}
|
||||
{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:')}
|
||||
{t("credits.modal.everythingInCredits", "Everything in Credits, plus:")}
|
||||
</Text>
|
||||
{ENTERPRISE_PLAN_FEATURES.map((feature, index) => (
|
||||
<FeatureListItem key={index} included>
|
||||
@@ -449,34 +449,34 @@ export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalPr
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
borderColor: 'var(--text-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
borderColor: "var(--text-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t('credits.modal.contactSales', 'Contact Sales')}
|
||||
{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?')}{' '}
|
||||
{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' }}
|
||||
style={{ color: "var(--mantine-color-blue-6)", textDecoration: "none" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.textDecoration = 'underline';
|
||||
e.currentTarget.style.textDecoration = "underline";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.textDecoration = 'none';
|
||||
e.currentTarget.style.textDecoration = "none";
|
||||
}}
|
||||
>
|
||||
{t('credits.modal.selfHostLink', 'Review the docs and plans')}
|
||||
{t("credits.modal.selfHostLink", "Review the docs and plans")}
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
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
|
||||
@@ -27,7 +27,13 @@ export function CreditModalBootstrap() {
|
||||
// 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) {
|
||||
if (
|
||||
isSaaSMode &&
|
||||
lastFetchTime !== null &&
|
||||
plansLastFetchTime === null &&
|
||||
creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD &&
|
||||
!isManagedTeamMember
|
||||
) {
|
||||
refreshPlans();
|
||||
}
|
||||
}, [isSaaSMode, lastFetchTime, plansLastFetchTime, creditBalance, isManagedTeamMember, refreshPlans]);
|
||||
@@ -70,10 +76,7 @@ export function CreditModalBootstrap() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreditExhaustedModal
|
||||
opened={exhaustedOpen && !insufficientOpen}
|
||||
onClose={() => setExhaustedOpen(false)}
|
||||
/>
|
||||
<CreditExhaustedModal opened={exhaustedOpen && !insufficientOpen} onClose={() => setExhaustedOpen(false)} />
|
||||
<InsufficientCreditsModal
|
||||
opened={insufficientOpen}
|
||||
onClose={() => setInsufficientOpen(false)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Divider, Group, Text, Progress, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Divider, Group, Text, Progress, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CreditUsageBannerProps {
|
||||
currentCredits: number;
|
||||
@@ -20,10 +20,10 @@ export function CreditUsageBanner({ currentCredits, totalCredits }: CreditUsageB
|
||||
<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')}
|
||||
{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', {
|
||||
<Text size="md" fw={600} style={{ color: "var(--text-primary)" }}>
|
||||
{t("credits.modal.creditsRemaining", "{{current}} of {{total}} remaining", {
|
||||
current: currentCredits,
|
||||
total: totalCredits,
|
||||
})}
|
||||
@@ -35,7 +35,7 @@ export function CreditUsageBanner({ currentCredits, totalCredits }: CreditUsageB
|
||||
radius="xl"
|
||||
color="blue"
|
||||
styles={{
|
||||
root: { backgroundColor: 'var(--bg-raised)' },
|
||||
root: { backgroundColor: "var(--bg-raised)" },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group, Text } from '@mantine/core';
|
||||
import CheckCircleIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
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;
|
||||
@@ -8,26 +8,26 @@ interface FeatureListItemProps {
|
||||
color?: string;
|
||||
dimmed?: boolean;
|
||||
fw?: number;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | string;
|
||||
size?: "xs" | "sm" | "md" | "lg" | string;
|
||||
}
|
||||
|
||||
export function FeatureListItem({
|
||||
children,
|
||||
included,
|
||||
color = 'var(--color-primary-600)',
|
||||
color = "var(--color-primary-600)",
|
||||
dimmed = false,
|
||||
fw = 400,
|
||||
size = 'sm'
|
||||
size = "sm",
|
||||
}: FeatureListItemProps) {
|
||||
const Icon = included ? CheckCircleIcon : CloseIcon;
|
||||
const iconColor = included ? color : 'var(--color-red-600)';
|
||||
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
|
||||
lg: 20,
|
||||
};
|
||||
|
||||
// Determine icon size - use mapped value if it exists, otherwise use the string directly
|
||||
@@ -38,10 +38,8 @@ export function FeatureListItem({
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Icon
|
||||
sx={{ fontSize: iconSize, color: iconColor, flexShrink: 0, marginTop: '2px' }}
|
||||
/>
|
||||
<Text size={textSize} c={dimmed ? 'dimmed' : undefined} fw={fw} style={textSize ? undefined : { fontSize: size }}>
|
||||
<Icon sx={{ fontSize: iconSize, color: iconColor, flexShrink: 0, marginTop: "2px" }} />
|
||||
<Text size={textSize} c={dimmed ? "dimmed" : undefined} fw={fw} style={textSize ? undefined : { fontSize: size }}>
|
||||
{children}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
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;
|
||||
@@ -18,12 +18,7 @@ interface InsufficientCreditsModalProps {
|
||||
* Desktop Insufficient Credits Modal
|
||||
* Shows when user attempts operation without enough credits
|
||||
*/
|
||||
export function InsufficientCreditsModal({
|
||||
opened,
|
||||
onClose,
|
||||
toolId,
|
||||
requiredCredits,
|
||||
}: InsufficientCreditsModalProps) {
|
||||
export function InsufficientCreditsModal({ opened, onClose, toolId, requiredCredits }: InsufficientCreditsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { creditBalance, tier, refreshBilling, isManagedTeamMember } = useSaaSBilling();
|
||||
const { isTeamLeader } = useSaaSTeam();
|
||||
@@ -32,10 +27,10 @@ export function InsufficientCreditsModal({
|
||||
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
|
||||
refreshBilling,
|
||||
onClose,
|
||||
'InsufficientCreditsModal'
|
||||
"InsufficientCreditsModal",
|
||||
);
|
||||
|
||||
const toolName = toolId ? t(`tool.${toolId}.name`, toolId) : t('common.operation', 'this operation');
|
||||
const toolName = toolId ? t(`tool.${toolId}.name`, toolId) : t("common.operation", "this operation");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -47,9 +42,9 @@ export function InsufficientCreditsModal({
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<WarningIcon sx={{ fontSize: 24, color: 'var(--mantine-color-orange-6)' }} />
|
||||
<WarningIcon sx={{ fontSize: 24, color: "var(--mantine-color-orange-6)" }} />
|
||||
<Text size="lg" fw={500}>
|
||||
{t('credits.insufficient.title', 'Insufficient Credits')}
|
||||
{t("credits.insufficient.title", "Insufficient Credits")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
@@ -59,21 +54,21 @@ export function InsufficientCreditsModal({
|
||||
<Text size="sm">
|
||||
{requiredCredits
|
||||
? t(
|
||||
'credits.insufficient.messageWithAmount',
|
||||
'You need {{required}} credits to run {{tool}}, but you only have {{current}}.',
|
||||
"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.',
|
||||
"credits.insufficient.message",
|
||||
"You do not have enough credits to run {{tool}}. You currently have {{current}} credits.",
|
||||
{
|
||||
tool: toolName,
|
||||
current: creditBalance,
|
||||
}
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
@@ -81,28 +76,18 @@ export function InsufficientCreditsModal({
|
||||
{isManagedTeamMember ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'credits.insufficient.managedMember',
|
||||
'Please contact your team leader for assistance.'
|
||||
)}
|
||||
{t("credits.insufficient.managedMember", "Please contact your team leader for assistance.")}
|
||||
</Text>
|
||||
<Button onClick={onClose} fullWidth>
|
||||
{t('common.close', 'Close')}
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</>
|
||||
) : tier === 'team' ? (
|
||||
) : tier === "team" ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'credits.insufficient.teamMember',
|
||||
'Enable overage billing to never run out of credits.'
|
||||
)}
|
||||
{t("credits.insufficient.teamMember", "Enable overage billing to never run out of credits.")}
|
||||
</Text>
|
||||
{meteringError && (
|
||||
<Alert color="red">
|
||||
{meteringError}
|
||||
</Alert>
|
||||
)}
|
||||
{meteringError && <Alert color="red">{meteringError}</Alert>}
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
@@ -111,38 +96,35 @@ export function InsufficientCreditsModal({
|
||||
loading={enablingMetering}
|
||||
disabled={!isTeamLeader}
|
||||
>
|
||||
{t('credits.enableOverageBilling', 'Enable Overage Billing')}
|
||||
{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')}
|
||||
{t("credits.modal.teamLeaderOnly", "Only team leaders can enable overage billing")}
|
||||
</Text>
|
||||
)}
|
||||
<Button onClick={onClose} variant="subtle" fullWidth disabled={enablingMetering}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'credits.insufficient.freeTier',
|
||||
'Upgrade to Team for 10x more credits and unlimited overage billing.'
|
||||
)}
|
||||
{t("credits.insufficient.freeTier", "Upgrade to Team for 10x more credits and unlimited overage billing.")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
openCheckout('pro');
|
||||
openCheckout("pro");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('credits.upgrade', 'Upgrade to Team')}
|
||||
{t("credits.upgrade", "Upgrade to Team")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="subtle" fullWidth>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import CoreToolButton from '@core/components/tools/toolPicker/ToolButton';
|
||||
import { getToolDisabledReason } from '@app/components/tools/fullscreen/shared';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
import { useState, useEffect } from "react";
|
||||
import CoreToolButton from "@core/components/tools/toolPicker/ToolButton";
|
||||
import { getToolDisabledReason } from "@app/components/tools/fullscreen/shared";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import { connectionModeService, type ConnectionMode } from "@app/services/connectionModeService";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
type CoreToolButtonProps = React.ComponentProps<typeof CoreToolButton>;
|
||||
|
||||
@@ -31,7 +31,7 @@ const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
|
||||
props.id as string,
|
||||
props.tool as ToolRegistryEntry,
|
||||
toolAvailability,
|
||||
premiumEnabled
|
||||
premiumEnabled,
|
||||
);
|
||||
|
||||
// In local mode, pass a handler so CoreToolButton renders the tool as "cloud-available"
|
||||
@@ -39,9 +39,7 @@ const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
|
||||
// user can see the settings; the disabled execute button handles the sign-in prompt.
|
||||
// comingSoon and selfHostedOffline tools remain dimmed — they have no usable UI to show.
|
||||
const handleUnavailableClick =
|
||||
connectionMode === 'local' &&
|
||||
disabledReason !== 'comingSoon' &&
|
||||
disabledReason !== 'selfHostedOffline'
|
||||
connectionMode === "local" && disabledReason !== "comingSoon" && disabledReason !== "selfHostedOffline"
|
||||
? () => handleToolSelectForced(props.id as ToolId)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
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.
|
||||
@@ -21,7 +21,7 @@ export function ToolPickerFooterExtensions() {
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
if (connectionMode !== 'local') return null;
|
||||
if (connectionMode !== "local") return null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
@@ -32,13 +32,13 @@ export function ToolPickerFooterExtensions() {
|
||||
px="sm"
|
||||
py={10}
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-default)',
|
||||
background: 'var(--bg-toolbar)',
|
||||
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.')}
|
||||
{t("localMode.toolPicker.message", "Sign in to unlock all tools.")}
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -47,7 +47,7 @@ export function ToolPickerFooterExtensions() {
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))}
|
||||
>
|
||||
{t('localMode.toolPicker.signIn', 'Sign In')}
|
||||
{t("localMode.toolPicker.signIn", "Sign In")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { 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) {
|
||||
@@ -13,7 +13,7 @@ export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (documentReady) {
|
||||
registerBridge('print', {
|
||||
registerBridge("print", {
|
||||
state: {},
|
||||
api: {
|
||||
print: () => {
|
||||
@@ -28,15 +28,15 @@ export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
|
||||
|
||||
print?.print?.();
|
||||
})().catch((error) => {
|
||||
console.error('[Desktop Print] Print failed', error);
|
||||
console.error("[Desktop Print] Print failed", error);
|
||||
});
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge('print', null);
|
||||
registerBridge("print", null);
|
||||
};
|
||||
}, [documentReady, file, fileName, print, registerBridge, url]);
|
||||
|
||||
|
||||
@@ -21,20 +21,20 @@ export const BILLING_CONFIG = {
|
||||
PLAN_PRICING_PRELOAD_THRESHOLD: 20,
|
||||
|
||||
// Stripe lookup keys
|
||||
PRO_PLAN_LOOKUP_KEY: 'plan:pro',
|
||||
METER_LOOKUP_KEY: 'meter:overage',
|
||||
PRO_PLAN_LOOKUP_KEY: "plan:pro",
|
||||
METER_LOOKUP_KEY: "meter:overage",
|
||||
|
||||
// Display formats
|
||||
CURRENCY_SYMBOLS: {
|
||||
gbp: '£',
|
||||
usd: '$',
|
||||
eur: '€',
|
||||
cny: '¥',
|
||||
inr: '₹',
|
||||
brl: 'R$',
|
||||
idr: 'Rp',
|
||||
jpy: '¥'
|
||||
} as const
|
||||
gbp: "£",
|
||||
usd: "$",
|
||||
eur: "€",
|
||||
cny: "¥",
|
||||
inr: "₹",
|
||||
brl: "R$",
|
||||
idr: "Rp",
|
||||
jpy: "¥",
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -49,8 +49,9 @@ export function getBillingConfig() {
|
||||
* @param currency Currency code (e.g., 'usd', 'gbp')
|
||||
* @param price Optional price override (defaults to BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT)
|
||||
*/
|
||||
export function getFormattedOveragePrice(currency: string = 'usd', price?: number): string {
|
||||
const symbol = BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || '$';
|
||||
export function getFormattedOveragePrice(currency: string = "usd", price?: number): string {
|
||||
const symbol =
|
||||
BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || "$";
|
||||
const amount = price ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
||||
return `${symbol}${amount.toFixed(2)}`;
|
||||
}
|
||||
@@ -59,5 +60,8 @@ export function getFormattedOveragePrice(currency: string = 'usd', price?: numbe
|
||||
* Get currency symbol from currency code
|
||||
*/
|
||||
export function getCurrencySymbol(currency: string): string {
|
||||
return BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || currency.toUpperCase();
|
||||
return (
|
||||
BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] ||
|
||||
currency.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { AppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
/**
|
||||
* Default configuration used while the bundled backend starts up.
|
||||
|
||||
@@ -10,77 +10,77 @@ export interface PlanFeatureConfig {
|
||||
|
||||
export const FREE_PLAN_FEATURES: PlanFeatureConfig[] = [
|
||||
{
|
||||
translationKey: 'credits.modal.allInOneWorkspace',
|
||||
defaultText: 'All-in-one PDF workspace (viewer, tools & agent)'
|
||||
translationKey: "credits.modal.allInOneWorkspace",
|
||||
defaultText: "All-in-one PDF workspace (viewer, tools & agent)",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.fullyPrivateFiles',
|
||||
defaultText: 'Fully private files'
|
||||
translationKey: "credits.modal.fullyPrivateFiles",
|
||||
defaultText: "Fully private files",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.standardThroughput',
|
||||
defaultText: 'Standard throughput'
|
||||
translationKey: "credits.modal.standardThroughput",
|
||||
defaultText: "Standard throughput",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.customSmartFolders',
|
||||
defaultText: 'Custom Smart Folders'
|
||||
translationKey: "credits.modal.customSmartFolders",
|
||||
defaultText: "Custom Smart Folders",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.apiSandbox',
|
||||
defaultText: 'API sandbox'
|
||||
}
|
||||
translationKey: "credits.modal.apiSandbox",
|
||||
defaultText: "API sandbox",
|
||||
},
|
||||
];
|
||||
|
||||
export const TEAM_PLAN_FEATURES: PlanFeatureConfig[] = [
|
||||
{
|
||||
translationKey: 'credits.modal.unlimitedSeats',
|
||||
defaultText: 'Unlimited seats'
|
||||
translationKey: "credits.modal.unlimitedSeats",
|
||||
defaultText: "Unlimited seats",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.fasterThroughput',
|
||||
defaultText: '10x faster throughput'
|
||||
translationKey: "credits.modal.fasterThroughput",
|
||||
defaultText: "10x faster throughput",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.largeFileProcessing',
|
||||
defaultText: 'Large file processing'
|
||||
translationKey: "credits.modal.largeFileProcessing",
|
||||
defaultText: "Large file processing",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.premiumAiModels',
|
||||
defaultText: 'Premium AI models'
|
||||
translationKey: "credits.modal.premiumAiModels",
|
||||
defaultText: "Premium AI models",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.secureApiAccess',
|
||||
defaultText: 'Secure API access'
|
||||
translationKey: "credits.modal.secureApiAccess",
|
||||
defaultText: "Secure API access",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.prioritySupport',
|
||||
defaultText: 'Priority support'
|
||||
}
|
||||
translationKey: "credits.modal.prioritySupport",
|
||||
defaultText: "Priority support",
|
||||
},
|
||||
];
|
||||
|
||||
export const ENTERPRISE_PLAN_FEATURES: PlanFeatureConfig[] = [
|
||||
{
|
||||
translationKey: 'credits.modal.orgWideAccess',
|
||||
defaultText: 'Org-wide access controls'
|
||||
translationKey: "credits.modal.orgWideAccess",
|
||||
defaultText: "Org-wide access controls",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.privateDocCloud',
|
||||
defaultText: 'Private Document Cloud'
|
||||
translationKey: "credits.modal.privateDocCloud",
|
||||
defaultText: "Private Document Cloud",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.ragFineTuning',
|
||||
defaultText: 'RAG + fine-tuning'
|
||||
translationKey: "credits.modal.ragFineTuning",
|
||||
defaultText: "RAG + fine-tuning",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.unlimitedApiAccess',
|
||||
defaultText: 'Unlimited API access'
|
||||
translationKey: "credits.modal.unlimitedApiAccess",
|
||||
defaultText: "Unlimited API access",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.advancedMonitoring',
|
||||
defaultText: 'Advanced monitoring'
|
||||
translationKey: "credits.modal.advancedMonitoring",
|
||||
defaultText: "Advanced monitoring",
|
||||
},
|
||||
{
|
||||
translationKey: 'credits.modal.dedicatedSupportSlas',
|
||||
defaultText: 'Dedicated support & SLAs'
|
||||
}
|
||||
translationKey: "credits.modal.dedicatedSupportSlas",
|
||||
defaultText: "Dedicated support & SLAs",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import i18n from '@app/i18n';
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
export const BACKEND_NOT_READY_CODE = 'BACKEND_NOT_READY' as const;
|
||||
export const BACKEND_NOT_READY_CODE = "BACKEND_NOT_READY" as const;
|
||||
|
||||
export interface BackendNotReadyError extends Error {
|
||||
code: typeof BACKEND_NOT_READY_CODE;
|
||||
}
|
||||
|
||||
export function createBackendNotReadyError(): BackendNotReadyError {
|
||||
return Object.assign(new Error(i18n.t('backendHealth.starting', 'Backend starting up...')), {
|
||||
return Object.assign(new Error(i18n.t("backendHealth.starting", "Backend starting up...")), {
|
||||
code: BACKEND_NOT_READY_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
export function isBackendNotReadyError(error: unknown): error is BackendNotReadyError {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& 'code' in error
|
||||
&& (error as { code?: unknown }).code === BACKEND_NOT_READY_CODE;
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === BACKEND_NOT_READY_CODE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,4 @@ export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env.VITE_SAAS_B
|
||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
// Desktop deep link callback for Supabase email confirmations
|
||||
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
|
||||
export const DESKTOP_DEEP_LINK_CALLBACK = "stirlingpdf://auth/callback";
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
export const CREDIT_EVENTS = {
|
||||
EXHAUSTED: 'credits:exhausted',
|
||||
INSUFFICIENT: 'credits:insufficient',
|
||||
REFRESH_NEEDED: 'credits:refresh-needed',
|
||||
EXHAUSTED: "credits:exhausted",
|
||||
INSUFFICIENT: "credits:insufficient",
|
||||
REFRESH_NEEDED: "credits:refresh-needed",
|
||||
} as const;
|
||||
|
||||
export type CreditEventType = typeof CREDIT_EVENTS[keyof typeof CREDIT_EVENTS];
|
||||
export type CreditEventType = (typeof CREDIT_EVENTS)[keyof typeof CREDIT_EVENTS];
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
* CustomEvent name for opening the desktop sign-in modal (SetupWizard).
|
||||
* Kept in a leaf module so apiClientSetup and others avoid importing SignInModal (heavy graph).
|
||||
*/
|
||||
export const OPEN_SIGN_IN_EVENT = 'stirling:open-sign-in';
|
||||
export const OPEN_SIGN_IN_EVENT = "stirling:open-sign-in";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||
import { SaaSStripeCheckout } from '@app/components/shared/billing/SaaSStripeCheckout';
|
||||
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
|
||||
import { SaaSStripeCheckout } from "@app/components/shared/billing/SaaSStripeCheckout";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
|
||||
interface SaaSCheckoutContextType {
|
||||
opened: boolean;
|
||||
@@ -14,7 +14,7 @@ const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(u
|
||||
export const useSaaSCheckout = () => {
|
||||
const context = useContext(SaaSCheckoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useSaaSCheckout must be used within SaaSCheckoutProvider');
|
||||
throw new Error("useSaaSCheckout must be used within SaaSCheckoutProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -23,9 +23,7 @@ interface SaaSCheckoutProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
|
||||
children
|
||||
}) => {
|
||||
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({ children }) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
|
||||
@@ -46,11 +44,11 @@ export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
|
||||
// Internal success handler - automatically refreshes billing context
|
||||
const handleCheckoutSuccess = useCallback(async () => {
|
||||
// Wait for webhooks to process (2 seconds)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
try {
|
||||
await refreshBilling();
|
||||
} catch (error) {
|
||||
console.error('[SaaSCheckoutContext] Failed to refresh billing after checkout:', error);
|
||||
console.error("[SaaSCheckoutContext] Failed to refresh billing after checkout:", error);
|
||||
}
|
||||
}, [refreshBilling]);
|
||||
|
||||
@@ -64,12 +62,7 @@ export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<SaaSStripeCheckout
|
||||
opened={opened}
|
||||
onClose={closeCheckout}
|
||||
planId={selectedPlan}
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
/>
|
||||
<SaaSStripeCheckout opened={opened} onClose={closeCheckout} planId={selectedPlan} onSuccess={handleCheckoutSuccess} />
|
||||
</SaaSCheckoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
/**
|
||||
* Desktop implementation of SaaS Team Context
|
||||
@@ -91,7 +91,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === 'saas');
|
||||
setIsSaasMode(mode === "saas");
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === 'authenticated');
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
@@ -113,16 +113,16 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
const fetchMyTeams = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log('[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated');
|
||||
console.log("[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<Team[]>('/api/v1/team/my', { suppressErrorToast: true });
|
||||
const response = await apiClient.get<Team[]>("/api/v1/team/my", { suppressErrorToast: true });
|
||||
setTeams(response.data);
|
||||
|
||||
const activeTeam = response.data[0];
|
||||
console.log('[SaaSTeamContext] Current team set:', {
|
||||
console.log("[SaaSTeamContext] Current team set:", {
|
||||
teamId: activeTeam?.teamId,
|
||||
name: activeTeam?.name,
|
||||
isPersonal: activeTeam?.isPersonal,
|
||||
@@ -131,39 +131,47 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
setCurrentTeam(activeTeam || null);
|
||||
return activeTeam || null;
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch teams:', error);
|
||||
console.error("[SaaSTeamContext] Failed to fetch teams:", error);
|
||||
return null;
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
const fetchTeamMembers = useCallback(async (teamId: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log('[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated');
|
||||
return;
|
||||
}
|
||||
const fetchTeamMembers = useCallback(
|
||||
async (teamId: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log("[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`, { suppressErrorToast: true });
|
||||
setTeamMembers(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
try {
|
||||
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`, { suppressErrorToast: true });
|
||||
setTeamMembers(response.data);
|
||||
} catch (error) {
|
||||
console.error("[SaaSTeamContext] Failed to fetch team members:", error);
|
||||
}
|
||||
},
|
||||
[isSaasMode, isAuthenticated],
|
||||
);
|
||||
|
||||
const fetchTeamInvitations = useCallback(async (teamId?: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated || !teamId) {
|
||||
return;
|
||||
}
|
||||
const fetchTeamInvitations = useCallback(
|
||||
async (teamId?: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated || !teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`, { suppressErrorToast: true });
|
||||
setTeamInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch team invitations:', error);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
setTeamInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error("[SaaSTeamContext] Failed to fetch team invitations:", error);
|
||||
}
|
||||
},
|
||||
[isSaasMode, isAuthenticated],
|
||||
);
|
||||
|
||||
const fetchReceivedInvitations = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
@@ -171,14 +179,14 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SaaSTeamContext] Fetching received team invitations');
|
||||
console.log("[SaaSTeamContext] Fetching received team invitations");
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending', { suppressErrorToast: true });
|
||||
console.log('[SaaSTeamContext] Received invitations response:', response.data);
|
||||
const response = await apiClient.get<TeamInvitation[]>("/api/v1/team/invitations/pending", { suppressErrorToast: true });
|
||||
console.log("[SaaSTeamContext] Received invitations response:", response.data);
|
||||
setReceivedInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch received invitations:', error);
|
||||
console.error("[SaaSTeamContext] Failed to fetch received invitations:", error);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
@@ -214,18 +222,18 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
}, [currentTeam, isSaasMode, isAuthenticated, fetchTeamMembers, fetchTeamInvitations]);
|
||||
|
||||
const inviteUser = async (email: string) => {
|
||||
if (!currentTeam) throw new Error('No current team');
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post('/api/v1/team/invite', {
|
||||
await apiClient.post("/api/v1/team/invite", {
|
||||
teamId: currentTeam.teamId,
|
||||
email
|
||||
email,
|
||||
});
|
||||
await fetchTeamInvitations(currentTeam.teamId);
|
||||
};
|
||||
|
||||
const acceptInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
|
||||
await fetchReceivedInvitations();
|
||||
@@ -234,14 +242,14 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const rejectInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
|
||||
await fetchReceivedInvitations();
|
||||
};
|
||||
|
||||
const cancelInvitation = async (invitationId: number) => {
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
|
||||
if (currentTeam) {
|
||||
@@ -250,8 +258,8 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const removeMember = async (memberId: number) => {
|
||||
if (!currentTeam) throw new Error('No current team');
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.delete(`/api/v1/team/${currentTeam.teamId}/members/${memberId}`);
|
||||
await refreshTeams();
|
||||
@@ -259,8 +267,8 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const leaveTeam = async () => {
|
||||
if (!currentTeam) throw new Error('No current team');
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
|
||||
await refreshTeams();
|
||||
@@ -269,7 +277,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const refreshTeams = useCallback(async () => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log('[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated');
|
||||
console.log("[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,23 +296,25 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
const isPersonalTeam = currentTeam?.isPersonal ?? true;
|
||||
|
||||
return (
|
||||
<SaaSTeamContext.Provider value={{
|
||||
currentTeam,
|
||||
teams,
|
||||
teamMembers,
|
||||
teamInvitations,
|
||||
receivedInvitations,
|
||||
isTeamLeader,
|
||||
isPersonalTeam,
|
||||
loading,
|
||||
inviteUser,
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
cancelInvitation,
|
||||
removeMember,
|
||||
leaveTeam,
|
||||
refreshTeams
|
||||
}}>
|
||||
<SaaSTeamContext.Provider
|
||||
value={{
|
||||
currentTeam,
|
||||
teams,
|
||||
teamMembers,
|
||||
teamInvitations,
|
||||
receivedInvitations,
|
||||
isTeamLeader,
|
||||
isPersonalTeam,
|
||||
loading,
|
||||
inviteUser,
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
cancelInvitation,
|
||||
removeMember,
|
||||
leaveTeam,
|
||||
refreshTeams,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SaaSTeamContext.Provider>
|
||||
);
|
||||
@@ -313,7 +323,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
export function useSaaSTeam() {
|
||||
const context = useContext(SaaSTeamContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSaaSTeam must be used within a SaaSTeamProvider');
|
||||
throw new Error("useSaaSTeam must be used within a SaaSTeamProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } from 'react';
|
||||
import { saasBillingService, BillingStatus, PlanPrice } from '@app/services/saasBillingService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
|
||||
import type { TierLevel } from '@app/types/billing';
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } from "react";
|
||||
import { saasBillingService, BillingStatus, PlanPrice } from "@app/services/saasBillingService";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import type { TierLevel } from "@app/types/billing";
|
||||
|
||||
// How long plan pricing is considered fresh. Lives at module level so both the
|
||||
// provider and usePlanPricing (the consumer hook) can reference it.
|
||||
@@ -26,8 +26,8 @@ interface SaasBillingContextType {
|
||||
// Billing Status
|
||||
billingStatus: BillingStatus | null;
|
||||
tier: TierLevel;
|
||||
subscription: BillingStatus['subscription'];
|
||||
usage: BillingStatus['meterUsage'];
|
||||
subscription: BillingStatus["subscription"];
|
||||
usage: BillingStatus["meterUsage"];
|
||||
isTrialing: boolean;
|
||||
trialDaysRemaining?: number;
|
||||
price?: number;
|
||||
@@ -57,7 +57,7 @@ interface SaasBillingContextType {
|
||||
|
||||
const SaasBillingContext = createContext<SaasBillingContextType>({
|
||||
billingStatus: null,
|
||||
tier: 'free',
|
||||
tier: "free",
|
||||
subscription: null,
|
||||
usage: null,
|
||||
isTrialing: false,
|
||||
@@ -84,7 +84,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
const [plans, setPlans] = useState<Map<string, PlanPrice>>(new Map());
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
const [plansError, setPlansError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false); // Start false (lazy load)
|
||||
const [loading, setLoading] = useState(false); // Start false (lazy load)
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure).
|
||||
// lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context.
|
||||
@@ -113,7 +113,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === 'saas');
|
||||
setIsSaasMode(mode === "saas");
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === 'authenticated');
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
@@ -151,7 +151,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Cache check: Skip if fresh data exists (<5 min old)
|
||||
const now = Date.now();
|
||||
if (billingStatusRef.current && lastFetchTimeRef.current && (now - lastFetchTimeRef.current) < 300000) {
|
||||
if (billingStatusRef.current && lastFetchTimeRef.current && now - lastFetchTimeRef.current < 300000) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,8 +168,8 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
setLastFetchTimeValue(now);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[SaasBillingContext] Failed to fetch billing:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch billing');
|
||||
console.error("[SaasBillingContext] Failed to fetch billing:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch billing");
|
||||
// Don't clear billing status on error (preserve existing data)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -192,14 +192,14 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
setPlansError(null);
|
||||
|
||||
try {
|
||||
const priceMap = await saasBillingService.getAvailablePlans('usd');
|
||||
const priceMap = await saasBillingService.getAvailablePlans("usd");
|
||||
setPlans(priceMap);
|
||||
const now = Date.now();
|
||||
plansLastFetchTimeRef.current = now;
|
||||
setPlansLastFetchTimeValue(now);
|
||||
} catch (err) {
|
||||
console.error('[SaasBillingContext] Failed to fetch plans:', err);
|
||||
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
|
||||
console.error("[SaasBillingContext] Failed to fetch plans:", err);
|
||||
setPlansError(err instanceof Error ? err.message : "Failed to fetch plans");
|
||||
} finally {
|
||||
plansFetchInProgressRef.current = false;
|
||||
setPlansLoading(false);
|
||||
@@ -272,7 +272,7 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
const customEvent = e as CustomEvent<{ creditsRemaining: number }>;
|
||||
const newBalance = customEvent.detail?.creditsRemaining;
|
||||
|
||||
if (typeof newBalance === 'number' && billingStatus) {
|
||||
if (typeof newBalance === "number" && billingStatus) {
|
||||
// Update credit balance in billing status without full refresh
|
||||
const updated = { ...billingStatus, creditBalance: newBalance };
|
||||
billingStatusRef.current = updated;
|
||||
@@ -280,68 +280,76 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Dispatch exhausted event if credits hit 0
|
||||
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
|
||||
window.dispatchEvent(new CustomEvent('credits:exhausted', {
|
||||
detail: { previousBalance: billingStatus.creditBalance ?? 0, currentBalance: newBalance }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("credits:exhausted", {
|
||||
detail: { previousBalance: billingStatus.creditBalance ?? 0, currentBalance: newBalance },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('credits:updated', handleCreditsUpdated);
|
||||
window.addEventListener("credits:updated", handleCreditsUpdated);
|
||||
return () => {
|
||||
window.removeEventListener('credits:updated', handleCreditsUpdated);
|
||||
window.removeEventListener("credits:updated", handleCreditsUpdated);
|
||||
};
|
||||
}, [billingStatus]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
billingStatus,
|
||||
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
|
||||
subscription: billingStatus?.subscription ?? null,
|
||||
usage: billingStatus?.meterUsage ?? null,
|
||||
isTrialing: billingStatus?.isTrialing ?? false,
|
||||
trialDaysRemaining: billingStatus?.trialDaysRemaining,
|
||||
price: plans.get('team')?.price,
|
||||
currency: plans.get('team')?.currency,
|
||||
creditBalance: billingStatus?.creditBalance ?? 0,
|
||||
plans,
|
||||
plansLoading,
|
||||
plansError,
|
||||
plansLastFetchTime: plansLastFetchTimeValue,
|
||||
isManagedTeamMember,
|
||||
loading: loading || teamLoading,
|
||||
error,
|
||||
lastFetchTime: lastFetchTimeValue,
|
||||
refreshBilling,
|
||||
refreshCredits: refreshBilling,
|
||||
refreshPlans,
|
||||
openBillingPortal,
|
||||
}), [
|
||||
billingStatus, isManagedTeamMember, plans, plansLoading, plansError,
|
||||
plansLastFetchTimeValue, loading, teamLoading, error, lastFetchTimeValue,
|
||||
refreshBilling, refreshPlans, openBillingPortal,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SaasBillingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SaasBillingContext.Provider>
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
billingStatus,
|
||||
tier: isManagedTeamMember ? "team" : (billingStatus?.tier ?? "free"),
|
||||
subscription: billingStatus?.subscription ?? null,
|
||||
usage: billingStatus?.meterUsage ?? null,
|
||||
isTrialing: billingStatus?.isTrialing ?? false,
|
||||
trialDaysRemaining: billingStatus?.trialDaysRemaining,
|
||||
price: plans.get("team")?.price,
|
||||
currency: plans.get("team")?.currency,
|
||||
creditBalance: billingStatus?.creditBalance ?? 0,
|
||||
plans,
|
||||
plansLoading,
|
||||
plansError,
|
||||
plansLastFetchTime: plansLastFetchTimeValue,
|
||||
isManagedTeamMember,
|
||||
loading: loading || teamLoading,
|
||||
error,
|
||||
lastFetchTime: lastFetchTimeValue,
|
||||
refreshBilling,
|
||||
refreshCredits: refreshBilling,
|
||||
refreshPlans,
|
||||
openBillingPortal,
|
||||
}),
|
||||
[
|
||||
billingStatus,
|
||||
isManagedTeamMember,
|
||||
plans,
|
||||
plansLoading,
|
||||
plansError,
|
||||
plansLastFetchTimeValue,
|
||||
loading,
|
||||
teamLoading,
|
||||
error,
|
||||
lastFetchTimeValue,
|
||||
refreshBilling,
|
||||
refreshPlans,
|
||||
openBillingPortal,
|
||||
],
|
||||
);
|
||||
|
||||
return <SaasBillingContext.Provider value={contextValue}>{children}</SaasBillingContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSaaSBilling() {
|
||||
const context = useContext(SaasBillingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSaaSBilling must be used within SaasBillingProvider');
|
||||
throw new Error("useSaaSBilling must be used within SaasBillingProvider");
|
||||
}
|
||||
|
||||
// Lazy fetch: Trigger fetch on first access (after team context loads)
|
||||
// Note: context.loading includes teamLoading, so this waits for team to load
|
||||
useEffect(() => {
|
||||
const needsFetch =
|
||||
context.billingStatus === null &&
|
||||
context.lastFetchTime === null &&
|
||||
!context.loading &&
|
||||
!context.isManagedTeamMember; // Managed members don't need billing data
|
||||
context.billingStatus === null && context.lastFetchTime === null && !context.loading && !context.isManagedTeamMember; // Managed members don't need billing data
|
||||
|
||||
if (needsFetch) {
|
||||
context.refreshBilling();
|
||||
@@ -360,9 +368,7 @@ export function usePlanPricing() {
|
||||
const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } = useContext(SaasBillingContext);
|
||||
|
||||
useEffect(() => {
|
||||
const isFresh =
|
||||
plansLastFetchTime !== null &&
|
||||
Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
|
||||
const isFresh = plansLastFetchTime !== null && Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
|
||||
|
||||
if (!isFresh) {
|
||||
refreshPlans();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
@@ -17,18 +17,18 @@ export function useAccountLogout() {
|
||||
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
// Save server URL before clearing so user can easily reconnect (self-hosted only)
|
||||
if (currentConfig.mode === 'selfhosted' && currentConfig.server_config?.url) {
|
||||
localStorage.setItem('server_url', currentConfig.server_config.url);
|
||||
if (currentConfig.mode === "selfhosted" && currentConfig.server_config?.url) {
|
||||
localStorage.setItem("server_url", currentConfig.server_config.url);
|
||||
}
|
||||
// Always switch to local after logout so the app remains usable
|
||||
await connectionModeService.switchToLocal();
|
||||
|
||||
window.history.replaceState({}, '', '/');
|
||||
window.history.replaceState({}, "", "/");
|
||||
// No reload needed — AppProviders remounts the SaaS provider tree via
|
||||
// connectionModeService subscription when mode changes to local.
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
|
||||
console.warn("[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect", err);
|
||||
}
|
||||
|
||||
redirectToLogin();
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
*/
|
||||
export async function handleAuthCallbackSuccess(token: string): Promise<void> {
|
||||
// Notify desktop popup listeners (self-hosted SSO flow)
|
||||
const isDesktopPopup = typeof window !== 'undefined' && window.opener && window.name === 'stirling-desktop-sso';
|
||||
const isDesktopPopup = typeof window !== "undefined" && window.opener && window.name === "stirling-desktop-sso";
|
||||
if (isDesktopPopup) {
|
||||
try {
|
||||
window.opener.postMessage({ type: 'stirling-desktop-sso', token }, '*');
|
||||
window.opener.postMessage({ type: "stirling-desktop-sso", token }, "*");
|
||||
} catch (postError) {
|
||||
console.error('[AuthCallback] Failed to notify desktop window:', postError);
|
||||
console.error("[AuthCallback] Failed to notify desktop window:", postError);
|
||||
}
|
||||
|
||||
// Give the message a moment to flush before attempting to close
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authService } from '@app/services/authService';
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
/**
|
||||
* Desktop-specific auth cleanup hooks.
|
||||
@@ -7,7 +7,7 @@ export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
||||
try {
|
||||
await authService.localClearAuth();
|
||||
} catch (err) {
|
||||
console.warn('[AuthCleanup] Failed to clear desktop auth data after sign out', err);
|
||||
console.warn("[AuthCleanup] Failed to clear desktop auth data after sign out", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,16 @@ export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||
try {
|
||||
// Only clear if there's NO token in storage
|
||||
// If token exists, user just logged in and we should keep it
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
|
||||
console.log('[AuthCleanup] Login init check - token exists:', !!token, 'length:', token?.length || 0);
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("stirling_jwt") : null;
|
||||
console.log("[AuthCleanup] Login init check - token exists:", !!token, "length:", token?.length || 0);
|
||||
|
||||
if (!token) {
|
||||
console.log('[AuthCleanup] No token found on login init, clearing stale auth data');
|
||||
console.log("[AuthCleanup] No token found on login init, clearing stale auth data");
|
||||
await authService.localClearAuth();
|
||||
} else {
|
||||
console.log('[AuthCleanup] Token present on login init (length:', token.length, '), skipping cleanup (fresh login)');
|
||||
console.log("[AuthCleanup] Token present on login init (length:", token.length, "), skipping cleanup (fresh login)");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
|
||||
console.warn("[AuthCleanup] Failed to clear desktop auth data on login init", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ export function getCookieConsentOverrides(): Record<string, unknown> {
|
||||
return {
|
||||
cookie: {
|
||||
useLocalStorage: true, // Cookies don't reliably persist on desktop, but localStorage does
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
/**
|
||||
* Desktop-specific OAuth navigation: prefer popup/system browser, avoid hijacking main webview.
|
||||
@@ -17,7 +17,7 @@ export async function startOAuthNavigation(redirectUrl: string): Promise<boolean
|
||||
await authService.loginWithSelfHostedOAuth(providerPath, serverUrl);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[Desktop OAuthNavigation] Failed to start OAuth flow', error);
|
||||
console.warn("[Desktop OAuthNavigation] Failed to start OAuth flow", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import type { PlatformSessionUser } from '@proprietary/extensions/platformSessionBridge';
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { authService } from "@app/services/authService";
|
||||
import type { PlatformSessionUser } from "@proprietary/extensions/platformSessionBridge";
|
||||
|
||||
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||
try {
|
||||
@@ -9,7 +9,7 @@ export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||
// Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
|
||||
// This skips redundant backend validation in springAuthClient since desktop authService
|
||||
// already manages the token lifecycle
|
||||
return mode === 'saas' || mode === 'selfhosted';
|
||||
return mode === "saas" || mode === "selfhosted";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -33,9 +33,9 @@ export async function getPlatformSessionUser(): Promise<PlatformSessionUser | nu
|
||||
export async function refreshPlatformSession(): Promise<boolean> {
|
||||
try {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'saas') {
|
||||
if (mode === "saas") {
|
||||
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
|
||||
} else if (mode === 'selfhosted') {
|
||||
} else if (mode === "selfhosted") {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
return false;
|
||||
@@ -56,7 +56,7 @@ export async function savePlatformToken(token: string): Promise<void> {
|
||||
try {
|
||||
await authService.saveToken(token);
|
||||
} catch (error) {
|
||||
console.error('[PlatformBridge] Failed to save token:', error);
|
||||
console.error("[PlatformBridge] Failed to save token:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||
import { fileOpenService } from '@app/services/fileOpenService';
|
||||
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||
import { createQuickKey } from '@app/types/fileContext';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOpenedFile } from "@app/hooks/useOpenedFile";
|
||||
import { fileOpenService } from "@app/services/fileOpenService";
|
||||
import { useFileManagement } from "@app/contexts/file/fileHooks";
|
||||
import { createQuickKey } from "@app/types/fileContext";
|
||||
|
||||
/**
|
||||
* App initialization hook
|
||||
@@ -37,10 +37,10 @@ export function useAppInitialization(): void {
|
||||
if (!fileData) return null;
|
||||
|
||||
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||
type: 'application/pdf'
|
||||
type: "application/pdf",
|
||||
});
|
||||
|
||||
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||
console.log("[Desktop] Loaded file:", fileData.fileName);
|
||||
|
||||
return {
|
||||
file,
|
||||
@@ -48,19 +48,19 @@ export function useAppInitialization(): void {
|
||||
quickKey: createQuickKey(file),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load file:', filePath, error);
|
||||
console.error("[Desktop] Failed to load file:", filePath, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
).filter((entry): entry is { file: File; filePath: string; quickKey: string } => Boolean(entry));
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
const filesArray = loadedFiles.map(entry => entry.file);
|
||||
const quickKeyToPath = new Map(loadedFiles.map(entry => [entry.quickKey, entry.filePath]));
|
||||
const filesArray = loadedFiles.map((entry) => entry.file);
|
||||
const quickKeyToPath = new Map(loadedFiles.map((entry) => [entry.quickKey, entry.filePath]));
|
||||
|
||||
const addedFiles = await addFiles(filesArray, { selectFiles: true });
|
||||
addedFiles.forEach(file => {
|
||||
addedFiles.forEach((file) => {
|
||||
const localFilePath = quickKeyToPath.get(file.quickKey);
|
||||
if (localFilePath) {
|
||||
updateStirlingFileStub(file.fileId, { localFilePath });
|
||||
@@ -70,7 +70,7 @@ export function useAppInitialization(): void {
|
||||
console.log(`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load opened files:', error);
|
||||
console.error("[Desktop] Failed to load opened files:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { backendHealthMonitor } from '@app/services/backendHealthMonitor';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import type { BackendHealthState } from '@app/types/backendHealth';
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { backendHealthMonitor } from "@app/services/backendHealthMonitor";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import type { BackendHealthState } from "@app/types/backendHealth";
|
||||
|
||||
/**
|
||||
* Hook to read backend health state for UI (Run button, BackendHealthIndicator).
|
||||
@@ -20,17 +20,13 @@ import type { BackendHealthState } from '@app/types/backendHealth';
|
||||
*/
|
||||
export function useBackendHealth() {
|
||||
const [health, setHealth] = useState<BackendHealthState>(() => backendHealthMonitor.getSnapshot());
|
||||
const [serverStatus, setServerStatus] = useState(
|
||||
() => selfHostedServerMonitor.getSnapshot().status
|
||||
);
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(
|
||||
() => tauriBackendService.getBackendUrl()
|
||||
);
|
||||
const [serverStatus, setServerStatus] = useState(() => selfHostedServerMonitor.getSnapshot().status);
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(() => tauriBackendService.getBackendUrl());
|
||||
const [connectionMode, setConnectionMode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
|
||||
return connectionModeService.subscribeToModeChanges((config) => setConnectionMode(config.mode));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +34,7 @@ export function useBackendHealth() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return selfHostedServerMonitor.subscribe(state => setServerStatus(state.status));
|
||||
return selfHostedServerMonitor.subscribe((state) => setServerStatus(state.status));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,10 +47,7 @@ export function useBackendHealth() {
|
||||
return backendHealthMonitor.checkNow();
|
||||
}, []);
|
||||
|
||||
const isOnline =
|
||||
connectionMode === 'selfhosted'
|
||||
? serverStatus !== 'offline' || !!localUrl
|
||||
: health.isOnline;
|
||||
const isOnline = connectionMode === "selfhosted" ? serverStatus !== "offline" || !!localUrl : health.isOnline;
|
||||
|
||||
return {
|
||||
...health,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||
import { useEndpointConfig } from '@app/hooks/useEndpointConfig';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { useEffect } from "react";
|
||||
import { useBackendHealth } from "@app/hooks/useBackendHealth";
|
||||
import { useEndpointConfig } from "@app/hooks/useEndpointConfig";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
|
||||
/**
|
||||
* Hook to initialize backend and monitor health
|
||||
@@ -32,12 +32,12 @@ export function useBackendInitializer(enabled = true) {
|
||||
void checkHealth();
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('[BackendInitializer] Failed to start backend:', error);
|
||||
console.error("[BackendInitializer] Failed to start backend:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Only start backend if it's not already starting/healthy
|
||||
if (status !== 'healthy' && status !== 'starting') {
|
||||
if (status !== "healthy" && status !== "starting") {
|
||||
void initializeBackend();
|
||||
}
|
||||
}, [enabled, status, backendUrl, checkHealth]);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
|
||||
import { getEndpointName } from '@app/utils/convertUtils';
|
||||
import { useState, useEffect } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { EXTENSION_TO_ENDPOINT } from "@app/constants/convertConstants";
|
||||
import { getEndpointName } from "@app/utils/convertUtils";
|
||||
|
||||
/**
|
||||
* Comprehensive conversion status data
|
||||
*/
|
||||
export interface ConversionStatus {
|
||||
availability: Record<string, boolean>; // Available on local OR SaaS?
|
||||
cloudStatus: Record<string, boolean>; // Will use cloud?
|
||||
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
|
||||
availability: Record<string, boolean>; // Available on local OR SaaS?
|
||||
cloudStatus: Record<string, boolean>; // Will use cloud?
|
||||
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,10 +33,10 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
|
||||
// Self-hosted offline path: server is down but local backend is available.
|
||||
// Check each conversion against the local backend only (no cloud routing).
|
||||
if (mode === 'selfhosted') {
|
||||
if (mode === "selfhosted") {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (status === 'offline' && localUrl) {
|
||||
if (status === "offline" && localUrl) {
|
||||
const pairs: [string, string, string][] = [];
|
||||
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
|
||||
for (const toExt of Object.keys(EXTENSION_TO_ENDPOINT[fromExt] || {})) {
|
||||
@@ -56,7 +56,7 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
} catch {
|
||||
return { key, supported: false };
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
for (const { key, supported } of results) {
|
||||
availability[key] = supported;
|
||||
@@ -77,7 +77,7 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== 'saas') {
|
||||
if (mode !== "saas") {
|
||||
// Non-SaaS, non-self-hosted: local endpoint checking handles everything
|
||||
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
|
||||
return;
|
||||
@@ -105,7 +105,7 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
// used by useMultipleEndpointsEnabled's SaaS enhancement.
|
||||
const availableLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointName,
|
||||
tauriBackendService.getBackendUrl()
|
||||
tauriBackendService.getBackendUrl(),
|
||||
);
|
||||
return { key, isAvailable: true, willUseCloud: !availableLocally, localOnly: false };
|
||||
} catch (error) {
|
||||
@@ -113,7 +113,7 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
// On error, assume available via cloud (safe default in SaaS mode)
|
||||
return { key, isAvailable: true, willUseCloud: true, localOnly: false };
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { key, isAvailable, willUseCloud: wuc, localOnly: lo } of results) {
|
||||
@@ -130,7 +130,7 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
|
||||
// Re-check when SaaS local backend becomes healthy
|
||||
const unsubLocal = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
if (status === "healthy") {
|
||||
checkConversions();
|
||||
}
|
||||
});
|
||||
@@ -143,7 +143,10 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
// Skip the first invocation since checkConversions() was already called above.
|
||||
let skipFirst = true;
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
if (skipFirst) { skipFirst = false; return; }
|
||||
if (skipFirst) {
|
||||
skipFirst = false;
|
||||
return;
|
||||
}
|
||||
void checkConversions();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
||||
import { useSaaSMode } from '@app/hooks/useSaaSMode';
|
||||
import { getToolCreditCost } from '@app/utils/creditCosts';
|
||||
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
|
||||
import { operationRouter } from '@app/services/operationRouter';
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSMode } from "@app/hooks/useSaaSMode";
|
||||
import { getToolCreditCost } from "@app/utils/creditCosts";
|
||||
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
/**
|
||||
* Desktop implementation of credit checking for cloud operations.
|
||||
@@ -21,43 +21,47 @@ export function useCreditCheck(operationType?: string, endpoint?: string) {
|
||||
const isSaaSMode = useSaaSMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const checkCredits = useCallback(async (runtimeEndpoint?: string): Promise<string | null> => {
|
||||
if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted
|
||||
if (!billing) return null;
|
||||
const checkCredits = useCallback(
|
||||
async (runtimeEndpoint?: string): Promise<string | null> => {
|
||||
if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted
|
||||
if (!billing) return null;
|
||||
|
||||
// If the operation routes to the local backend, no credits are consumed — skip check.
|
||||
// runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint.
|
||||
const ep = runtimeEndpoint ?? endpoint;
|
||||
if (ep) {
|
||||
try {
|
||||
const willUseSaaS = await operationRouter.willRouteToSaaS(ep);
|
||||
if (!willUseSaaS) return null;
|
||||
} catch {
|
||||
// If routing check fails, fall through to credit check as a safe default.
|
||||
// If the operation routes to the local backend, no credits are consumed — skip check.
|
||||
// runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint.
|
||||
const ep = runtimeEndpoint ?? endpoint;
|
||||
if (ep) {
|
||||
try {
|
||||
const willUseSaaS = await operationRouter.willRouteToSaaS(ep);
|
||||
if (!willUseSaaS) return null;
|
||||
} catch {
|
||||
// If routing check fails, fall through to credit check as a safe default.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { creditBalance, loading } = billing;
|
||||
const requiredCredits = getToolCreditCost(operationType as ToolId);
|
||||
const { creditBalance, loading } = billing;
|
||||
const requiredCredits = getToolCreditCost(operationType as ToolId);
|
||||
|
||||
if (!loading && creditBalance < requiredCredits) {
|
||||
window.dispatchEvent(new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, {
|
||||
detail: {
|
||||
operationType,
|
||||
requiredCredits,
|
||||
currentBalance: creditBalance,
|
||||
},
|
||||
}));
|
||||
if (!loading && creditBalance < requiredCredits) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, {
|
||||
detail: {
|
||||
operationType,
|
||||
requiredCredits,
|
||||
currentBalance: creditBalance,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return t(
|
||||
'credits.insufficient.brief',
|
||||
'Insufficient credits. You need {{required}} credits but have {{current}}.',
|
||||
{ required: requiredCredits, current: creditBalance },
|
||||
);
|
||||
}
|
||||
return t("credits.insufficient.brief", "Insufficient credits. You need {{required}} credits but have {{current}}.", {
|
||||
required: requiredCredits,
|
||||
current: creditBalance,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [billing, isSaaSMode, operationType, endpoint, t]);
|
||||
return null;
|
||||
},
|
||||
[billing, isSaaSMode, operationType, endpoint, t],
|
||||
);
|
||||
|
||||
return { checkCredits };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
||||
import { useSaaSMode } from '@app/hooks/useSaaSMode';
|
||||
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSMode } from "@app/hooks/useSaaSMode";
|
||||
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
|
||||
|
||||
/**
|
||||
* Desktop hook that monitors credit balance and dispatches events
|
||||
@@ -22,7 +22,7 @@ export function useCreditEvents() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
|
||||
detail: { previousBalance: prevBalance, currentBalance: creditBalance },
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { defaultAppService } from '@app/services/defaultAppService';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultAppService } from "@app/services/defaultAppService";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
export const useDefaultApp = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,7 +17,7 @@ export const useDefaultApp = () => {
|
||||
const status = await defaultAppService.isDefaultPdfHandler();
|
||||
setIsDefault(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to check default status:', error);
|
||||
console.error("Failed to check default status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,26 +26,26 @@ export const useDefaultApp = () => {
|
||||
try {
|
||||
const result = await defaultAppService.setAsDefaultPdfHandler();
|
||||
|
||||
if (result === 'set_successfully') {
|
||||
if (result === "set_successfully") {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('defaultApp.success.title', 'Default App Set'),
|
||||
body: t('defaultApp.success.message', 'Stirling PDF is now your default PDF editor'),
|
||||
alertType: "success",
|
||||
title: t("defaultApp.success.title", "Default App Set"),
|
||||
body: t("defaultApp.success.message", "Stirling PDF is now your default PDF editor"),
|
||||
});
|
||||
setIsDefault(true);
|
||||
} else if (result === 'opened_dialog') {
|
||||
} else if (result === "opened_dialog") {
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: t('defaultApp.settingsOpened.title', 'Settings Opened'),
|
||||
body: t('defaultApp.settingsOpened.message', 'Please select Stirling PDF in the file association dialogue'),
|
||||
alertType: "neutral",
|
||||
title: t("defaultApp.settingsOpened.title", "Settings Opened"),
|
||||
body: t("defaultApp.settingsOpened.message", "Please select Stirling PDF in the file association dialogue"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set default:', error);
|
||||
console.error("Failed to set default:", error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('defaultApp.error.title', 'Error'),
|
||||
body: t('defaultApp.error.message', 'Failed to set default PDF handler'),
|
||||
alertType: "error",
|
||||
title: t("defaultApp.error.title", "Error"),
|
||||
body: t("defaultApp.error.message", "Failed to set default PDF handler"),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
/**
|
||||
* Shared hook for enabling metered (overage) billing via Supabase edge function.
|
||||
@@ -13,7 +13,7 @@ import { authService } from '@app/services/authService';
|
||||
export function useEnableMeteredBilling(
|
||||
refreshBilling: () => Promise<void>,
|
||||
onSuccess: () => void,
|
||||
logPrefix: string
|
||||
logPrefix: string,
|
||||
): {
|
||||
enablingMetering: boolean;
|
||||
meteringError: string | null;
|
||||
@@ -30,20 +30,20 @@ export function useEnableMeteredBilling(
|
||||
try {
|
||||
const token = await authService.getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('create-meter-subscription', {
|
||||
method: 'POST',
|
||||
const { data, error } = await supabase.functions.invoke("create-meter-subscription", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to enable metered billing');
|
||||
throw new Error(error.message || "Failed to enable metered billing");
|
||||
}
|
||||
|
||||
if (!data?.success) {
|
||||
throw new Error(data?.error || data?.message || 'Failed to enable metered billing');
|
||||
throw new Error(data?.error || data?.message || "Failed to enable metered billing");
|
||||
}
|
||||
|
||||
console.debug(`[${logPrefix}] Metered billing enabled successfully`);
|
||||
@@ -51,7 +51,7 @@ export function useEnableMeteredBilling(
|
||||
await refreshBilling();
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to enable metered billing';
|
||||
const message = err instanceof Error ? err.message : "Failed to enable metered billing";
|
||||
console.error(`[${logPrefix}] Failed to enable metered billing:`, err);
|
||||
setMeteringError(message);
|
||||
} finally {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import type { AppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { isBackendNotReadyError } from "@app/constants/backendErrors";
|
||||
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
interface EndpointConfig {
|
||||
backendUrl: string;
|
||||
@@ -18,34 +17,31 @@ interface EndpointConfig {
|
||||
const RETRY_DELAY_MS = 2500;
|
||||
|
||||
function isSelfHostedOffline(): boolean {
|
||||
return (
|
||||
selfHostedServerMonitor.getSnapshot().status === 'offline' &&
|
||||
!!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
return selfHostedServerMonitor.getSnapshot().status === "offline" && !!tauriBackendService.getBackendUrl();
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (isAxiosError(err)) {
|
||||
const data = err.response?.data as { message?: string } | undefined;
|
||||
if (typeof data?.message === 'string') {
|
||||
if (typeof data?.message === "string") {
|
||||
return data.message;
|
||||
}
|
||||
return err.message || 'Unknown error occurred';
|
||||
return err.message || "Unknown error occurred";
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return 'Unknown error occurred';
|
||||
return "Unknown error occurred";
|
||||
}
|
||||
|
||||
async function checkDependenciesReady(): Promise<boolean> {
|
||||
try {
|
||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', {
|
||||
const response = await apiClient.get<AppConfig>("/api/v1/config/app-config", {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
return response.data?.dependenciesReady ?? false;
|
||||
} catch (error) {
|
||||
console.debug('[useEndpointConfig] Dependencies not ready yet:', error);
|
||||
console.debug("[useEndpointConfig] Dependencies not ready yet:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -100,9 +96,12 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
const response = await apiClient.get<boolean>(
|
||||
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
|
||||
const locallyEnabled = response.data;
|
||||
|
||||
@@ -110,7 +109,7 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
|
||||
// Even if not supported locally, they will route to SaaS backend
|
||||
if (mode === 'saas') {
|
||||
if (mode === "saas") {
|
||||
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
|
||||
setEnabled(true);
|
||||
return;
|
||||
@@ -123,7 +122,7 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
if (isBackendStarting) {
|
||||
setError(t('backendHealth.starting', 'Backend starting up...'));
|
||||
setError(t("backendHealth.starting", "Backend starting up..."));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
@@ -133,7 +132,7 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
} else {
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume available even on check failure
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'saas') {
|
||||
if (mode === "saas") {
|
||||
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
|
||||
setEnabled(true); // Available via SaaS
|
||||
setError(null);
|
||||
@@ -174,7 +173,7 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
if (status === "healthy") {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
});
|
||||
@@ -234,7 +233,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// checkDependenciesReady() would fail here since it hits the offline remote server.
|
||||
const { status: serverStatus } = selfHostedServerMonitor.getSnapshot();
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (serverStatus === 'offline' && localUrl) {
|
||||
if (serverStatus === "offline" && localUrl) {
|
||||
const results = await Promise.all(
|
||||
[...new Set(endpoints)].map(async (ep) => {
|
||||
try {
|
||||
@@ -243,17 +242,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
} catch {
|
||||
return { ep, supported: false };
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
if (!isMountedRef.current) return;
|
||||
const statusMap: Record<string, boolean> = {};
|
||||
const details: Record<string, EndpointAvailabilityDetails> = {};
|
||||
for (const { ep, supported } of results) {
|
||||
statusMap[ep] = supported;
|
||||
details[ep] = { enabled: supported, reason: supported ? null : 'NOT_SUPPORTED_LOCALLY' };
|
||||
details[ep] = { enabled: supported, reason: supported ? null : "NOT_SUPPORTED_LOCALLY" };
|
||||
}
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
|
||||
setEndpointDetails((prev) => ({ ...prev, ...details }));
|
||||
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -271,60 +270,66 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// "large query reduction" change and still require the parameter.
|
||||
let response: Awaited<ReturnType<typeof apiClient.get<Record<string, EndpointAvailabilityDetails>>>>;
|
||||
try {
|
||||
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
|
||||
`/api/v1/config/endpoints-availability`,
|
||||
{ suppressErrorToast: true }
|
||||
);
|
||||
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability`, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
} catch (innerErr) {
|
||||
if (isAxiosError(innerErr) && innerErr.response?.status === 400) {
|
||||
// Old server — requires explicit endpoints query param
|
||||
console.debug('[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format');
|
||||
const endpointsParam = endpoints.join(',');
|
||||
console.debug("[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format");
|
||||
const endpointsParam = endpoints.join(",");
|
||||
response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
|
||||
{ suppressErrorToast: true }
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
} else {
|
||||
throw innerErr;
|
||||
}
|
||||
}
|
||||
|
||||
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? false,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, EndpointAvailabilityDetails>);
|
||||
const details = Object.entries(response.data).reduce(
|
||||
(acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? false,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, EndpointAvailabilityDetails>,
|
||||
);
|
||||
|
||||
const statusMap = Object.keys(details).reduce((acc, key) => {
|
||||
acc[key] = details[key].enabled;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
const statusMap = Object.keys(details).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = details[key].enabled;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
|
||||
// They will route to SaaS backend
|
||||
if (mode === 'saas') {
|
||||
const disabledEndpoints = Object.keys(details).filter(key => !details[key].enabled);
|
||||
if (mode === "saas") {
|
||||
const disabledEndpoints = Object.keys(details).filter((key) => !details[key].enabled);
|
||||
|
||||
for (const endpoint of disabledEndpoints) {
|
||||
console.debug(`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
|
||||
console.debug(
|
||||
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
|
||||
);
|
||||
statusMap[endpoint] = true; // Mark as enabled via SaaS
|
||||
details[endpoint] = { enabled: true, reason: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
|
||||
setEndpointDetails((prev) => ({ ...prev, ...details }));
|
||||
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
if (isBackendStarting) {
|
||||
setError(t('backendHealth.starting', 'Backend starting up...'));
|
||||
setError(t("backendHealth.starting", "Backend starting up..."));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
@@ -333,16 +338,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
}
|
||||
} else {
|
||||
setError(message);
|
||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||
const fallbackDetail: EndpointAvailabilityDetails = { enabled: false, reason: 'UNKNOWN' };
|
||||
acc.status[endpointName] = false;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
|
||||
const fallbackStatus = endpoints.reduce(
|
||||
(acc, endpointName) => {
|
||||
const fallbackDetail: EndpointAvailabilityDetails = { enabled: false, reason: "UNKNOWN" };
|
||||
acc.status[endpointName] = false;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> },
|
||||
);
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all endpoints as available
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'saas') {
|
||||
if (mode === "saas") {
|
||||
for (const endpoint of endpoints) {
|
||||
console.debug(`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
|
||||
fallbackStatus.status[endpoint] = true;
|
||||
@@ -351,7 +359,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
}
|
||||
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
|
||||
setEndpointDetails((prev) => ({ ...prev, ...fallbackStatus.details }));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -381,7 +389,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
if (status === "healthy") {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
});
|
||||
@@ -401,9 +409,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
}
|
||||
|
||||
// Default backend URL from environment variables
|
||||
const DEFAULT_BACKEND_URL =
|
||||
import.meta.env.VITE_DESKTOP_BACKEND_URL
|
||||
|| import.meta.env.VITE_API_BASE_URL;
|
||||
const DEFAULT_BACKEND_URL = import.meta.env.VITE_DESKTOP_BACKEND_URL || import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
/**
|
||||
* Desktop override exposing the backend URL based on connection mode.
|
||||
@@ -414,9 +420,10 @@ export function useEndpointConfig(): EndpointConfig {
|
||||
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
|
||||
|
||||
useEffect(() => {
|
||||
connectionModeService.getCurrentConfig()
|
||||
connectionModeService
|
||||
.getCurrentConfig()
|
||||
.then((config) => {
|
||||
if (config.mode === 'selfhosted' && config.server_config?.url) {
|
||||
if (config.mode === "selfhosted" && config.server_config?.url) {
|
||||
setBackendUrl(config.server_config.url);
|
||||
} else {
|
||||
// SaaS mode - use default from env vars (local backend)
|
||||
@@ -424,7 +431,7 @@ export function useEndpointConfig(): EndpointConfig {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to get connection config:', err);
|
||||
console.error("Failed to get connection config:", err);
|
||||
// Keep current URL on error
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useExitWarning() {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,29 +26,29 @@ export function useExitWarning() {
|
||||
}
|
||||
|
||||
const allStubs = selectorsRef.current.getStirlingFileStubs();
|
||||
const dirtyStubs = allStubs.filter(stub => stub.isDirty);
|
||||
const dirtyStubs = allStubs.filter((stub) => stub.isDirty);
|
||||
|
||||
if (dirtyStubs.length > 0) {
|
||||
const fileList = dirtyStubs.map(f => `• ${f.name}`).join('\n');
|
||||
const saveLabel = t('confirmCloseSave', 'Save and close');
|
||||
const discardLabel = t('confirmCloseDiscard', 'Discard changes and close');
|
||||
const cancelLabel = t('confirmCloseCancel', 'Cancel');
|
||||
const fileList = dirtyStubs.map((f) => `• ${f.name}`).join("\n");
|
||||
const saveLabel = t("confirmCloseSave", "Save and close");
|
||||
const discardLabel = t("confirmCloseDiscard", "Discard changes and close");
|
||||
const cancelLabel = t("confirmCloseCancel", "Cancel");
|
||||
|
||||
const choice = await message(
|
||||
t(
|
||||
'confirmCloseUnsavedList',
|
||||
'You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}',
|
||||
{ count: dirtyStubs.length, plural: dirtyStubs.length > 1 ? 's' : '', fileList }
|
||||
),
|
||||
t("confirmCloseUnsavedList", "You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}", {
|
||||
count: dirtyStubs.length,
|
||||
plural: dirtyStubs.length > 1 ? "s" : "",
|
||||
fileList,
|
||||
}),
|
||||
{
|
||||
title: t('confirmCloseUnsaved', 'This file has unsaved changes.'),
|
||||
kind: 'warning',
|
||||
title: t("confirmCloseUnsaved", "This file has unsaved changes."),
|
||||
kind: "warning",
|
||||
buttons: {
|
||||
yes: saveLabel,
|
||||
no: discardLabel,
|
||||
cancel: cancelLabel,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (choice === cancelLabel) {
|
||||
@@ -62,12 +62,11 @@ export function useExitWarning() {
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
await message(
|
||||
t(
|
||||
'confirmCloseSaveFailed',
|
||||
'Saved with errors. {{count}} file{{plural}} could not be saved.',
|
||||
{ count: failedCount, plural: failedCount > 1 ? 's' : '' }
|
||||
),
|
||||
{ title: t('confirmCloseSaveFailedTitle', 'Save Failed'), kind: 'error' }
|
||||
t("confirmCloseSaveFailed", "Saved with errors. {{count}} file{{plural}} could not be saved.", {
|
||||
count: failedCount,
|
||||
plural: failedCount > 1 ? "s" : "",
|
||||
}),
|
||||
{ title: t("confirmCloseSaveFailedTitle", "Save Failed"), kind: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -80,21 +79,21 @@ export function useExitWarning() {
|
||||
try {
|
||||
await appWindow.destroy();
|
||||
} catch (error) {
|
||||
console.error('[exit-warning] destroy failed', error);
|
||||
console.error("[exit-warning] destroy failed", error);
|
||||
isClosingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unlisten = appWindow.onCloseRequested(handleCloseRequested);
|
||||
return () => {
|
||||
unlisten.then(fn => {
|
||||
unlisten.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, [fileActions, t]);
|
||||
|
||||
const saveDirtyFiles = async (dirtyStubs: StirlingFileStub[]) => {
|
||||
const filesById = new Map(selectorsRef.current.getFiles().map(file => [file.fileId, file]));
|
||||
const filesById = new Map(selectorsRef.current.getFiles().map((file) => [file.fileId, file]));
|
||||
let failedCount = 0;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -122,7 +121,7 @@ export function useExitWarning() {
|
||||
if (result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||
import SaveOutlinedIcon from "@mui/icons-material/SaveOutlined";
|
||||
|
||||
/**
|
||||
* File action icons for desktop builds.
|
||||
@@ -10,10 +10,10 @@ export function useFileActionIcons() {
|
||||
return {
|
||||
upload: FolderOpenOutlinedIcon,
|
||||
download: SaveOutlinedIcon,
|
||||
uploadIconName: 'folder-rounded' as const,
|
||||
downloadIconName: 'save-rounded' as const,
|
||||
uploadIconName: "folder-rounded" as const,
|
||||
downloadIconName: "save-rounded" as const,
|
||||
// Returning this icon name causes RightRail to render the Save As button.
|
||||
// On desktop, downloadFile() without a localPath shows a native save dialog.
|
||||
saveAsIconName: 'save-as-rounded' as string | undefined,
|
||||
saveAsIconName: "save-as-rounded" as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* File action terminology for desktop builds
|
||||
@@ -8,17 +8,17 @@ export function useFileActionTerminology() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
uploadFiles: t('fileUpload.openFiles', 'Open Files'),
|
||||
uploadFile: t('fileUpload.openFile', 'Open File'),
|
||||
upload: t('fileUpload.open', 'Open'),
|
||||
dropFilesHere: t('fileUpload.dropFilesHereOpen', 'Drop files here or click the open button'),
|
||||
addFiles: t('fileUpload.openFiles', 'Open Files'),
|
||||
mobileUpload: t('landing.mobileUpload', 'Upload from Mobile'),
|
||||
uploadFromComputer: t('landing.openFromComputer', 'Open from computer'),
|
||||
download: t('save', 'Save'),
|
||||
downloadAll: t('rightRail.saveAll', 'Save All'),
|
||||
downloadSelected: t('fileManager.saveSelected', 'Save Selected'),
|
||||
downloadUnavailable: t('saveUnavailable', 'Save unavailable for this item'),
|
||||
noFilesInStorage: t('fileUpload.noFilesInStorageOpen', 'No files available in storage. Open some files first.'),
|
||||
uploadFiles: t("fileUpload.openFiles", "Open Files"),
|
||||
uploadFile: t("fileUpload.openFile", "Open File"),
|
||||
upload: t("fileUpload.open", "Open"),
|
||||
dropFilesHere: t("fileUpload.dropFilesHereOpen", "Drop files here or click the open button"),
|
||||
addFiles: t("fileUpload.openFiles", "Open Files"),
|
||||
mobileUpload: t("landing.mobileUpload", "Upload from Mobile"),
|
||||
uploadFromComputer: t("landing.openFromComputer", "Open from computer"),
|
||||
download: t("save", "Save"),
|
||||
downloadAll: t("rightRail.saveAll", "Save All"),
|
||||
downloadSelected: t("fileManager.saveSelected", "Save Selected"),
|
||||
downloadUnavailable: t("saveUnavailable", "Save unavailable for this item"),
|
||||
noFilesInStorage: t("fileUpload.noFilesInStorageOpen", "No files available in storage. Open some files first."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
/**
|
||||
* First launch check hook
|
||||
@@ -27,7 +27,7 @@ export function useFirstLaunchCheck(): { isFirstLaunch: boolean; setupComplete:
|
||||
|
||||
setupCheckCompleteRef.current = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to check first launch:', error);
|
||||
console.error("Failed to check first launch:", error);
|
||||
// On error, assume not first launch and proceed
|
||||
setIsFirstLaunch(false);
|
||||
setSetupComplete(true);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import type { GroupEnabledResult } from '@app/types/groupEnabled';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import type { GroupEnabledResult } from "@app/types/groupEnabled";
|
||||
|
||||
const OFFLINE_REASON_FALLBACK = 'Requires your Stirling-PDF server (currently offline)';
|
||||
const OFFLINE_REASON_FALLBACK = "Requires your Stirling-PDF server (currently offline)";
|
||||
|
||||
/**
|
||||
* Desktop override: skips the network request entirely when the self-hosted
|
||||
@@ -18,7 +18,7 @@ export function useGroupEnabled(group: string): GroupEnabledResult {
|
||||
// the very first render when offline (t() is not available in useState initialiser).
|
||||
const [result, setResult] = useState<GroupEnabledResult>(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === 'offline') {
|
||||
if (status === "offline") {
|
||||
return { enabled: false, unavailableReason: OFFLINE_REASON_FALLBACK };
|
||||
}
|
||||
return { enabled: null, unavailableReason: null };
|
||||
@@ -26,17 +26,17 @@ export function useGroupEnabled(group: string): GroupEnabledResult {
|
||||
|
||||
useEffect(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === 'offline') {
|
||||
if (status === "offline") {
|
||||
setResult({
|
||||
enabled: false,
|
||||
unavailableReason: t('toolPanel.fullscreen.selfHostedOffline', OFFLINE_REASON_FALLBACK),
|
||||
unavailableReason: t("toolPanel.fullscreen.selfHostedOffline", OFFLINE_REASON_FALLBACK),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
apiClient
|
||||
.get<boolean>(`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`)
|
||||
.then(res => setResult({ enabled: res.data, unavailableReason: null }))
|
||||
.then((res) => setResult({ enabled: res.data, unavailableReason: null }))
|
||||
.catch(() => setResult({ enabled: false, unavailableReason: null }));
|
||||
}, [group, t]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useSelfHostedAuth } from '@app/hooks/useSelfHostedAuth';
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
|
||||
|
||||
/**
|
||||
* Desktop override: shared (group) signing requires self-hosted mode AND
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { fileOpenService } from '@app/services/fileOpenService';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { fileOpenService } from "@app/services/fileOpenService";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export function useOpenedFile() {
|
||||
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
||||
@@ -22,10 +22,10 @@ export function useOpenedFile() {
|
||||
useEffect(() => {
|
||||
// Function to read and process files from storage
|
||||
const readFilesFromStorage = async () => {
|
||||
console.log('🔍 Reading files from storage...');
|
||||
console.log("🔍 Reading files from storage...");
|
||||
try {
|
||||
const filePaths = await fileOpenService.getOpenedFiles();
|
||||
console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths);
|
||||
console.log("🔍 fileOpenService.getOpenedFiles() returned:", filePaths);
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
|
||||
@@ -33,7 +33,7 @@ export function useOpenedFile() {
|
||||
setOpenedFilePaths(filePaths);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to read files from storage:', error);
|
||||
console.error("❌ Failed to read files from storage:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -44,10 +44,10 @@ export function useOpenedFile() {
|
||||
|
||||
// Listen for files-changed events (when new files are added to storage)
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('files-changed', async () => {
|
||||
console.log('📂 files-changed event received, re-reading storage...');
|
||||
listen("files-changed", async () => {
|
||||
console.log("📂 files-changed event received, re-reading storage...");
|
||||
await readFilesFromStorage();
|
||||
}).then(unlistenFn => {
|
||||
}).then((unlistenFn) => {
|
||||
unlisten = unlistenFn;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { useState, useEffect } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
/**
|
||||
* Returns whether the app is currently in SaaS connection mode.
|
||||
@@ -10,8 +10,8 @@ export function useSaaSMode(): boolean {
|
||||
const [isSaaSMode, setIsSaaSMode] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(mode => setIsSaaSMode(mode === 'saas'));
|
||||
return connectionModeService.subscribeToModeChanges(cfg => setIsSaaSMode(cfg.mode === 'saas'));
|
||||
void connectionModeService.getCurrentMode().then((mode) => setIsSaaSMode(mode === "saas"));
|
||||
return connectionModeService.subscribeToModeChanges((cfg) => setIsSaaSMode(cfg.mode === "saas"));
|
||||
}, []);
|
||||
|
||||
return isSaaSMode;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSaaSBilling, usePlanPricing } from '@app/contexts/SaasBillingContext';
|
||||
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures';
|
||||
import type { TierLevel } from '@app/types/billing';
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSBilling, usePlanPricing } from "@app/contexts/SaasBillingContext";
|
||||
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from "@app/config/planFeatures";
|
||||
import type { TierLevel } from "@app/types/billing";
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
@@ -28,60 +28,60 @@ export const useSaaSPlans = () => {
|
||||
const { plans, plansLoading, plansError } = usePlanPricing();
|
||||
|
||||
const computedPlans = useMemo<PlanTier[]>(() => {
|
||||
const teamPlan = plans.get('team');
|
||||
const teamPlan = plans.get("team");
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('plan.free.name', 'Free'),
|
||||
id: "free",
|
||||
name: t("plan.free.name", "Free"),
|
||||
price: 0,
|
||||
currency: '$',
|
||||
period: t('plan.period.month', '/month'),
|
||||
highlights: FREE_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
|
||||
currency: "$",
|
||||
period: t("plan.period.month", "/month"),
|
||||
highlights: FREE_PLAN_FEATURES.map((f) => t(f.translationKey, f.defaultText)),
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
|
||||
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: false },
|
||||
{ name: t('plan.feature.api', 'API Access'), included: false },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
|
||||
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
|
||||
]
|
||||
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
|
||||
{ name: t("plan.feature.fileSize", "File Size Limit"), included: false },
|
||||
{ name: t("plan.feature.automation", "Automate tool workflows"), included: false },
|
||||
{ name: t("plan.feature.api", "API Access"), included: false },
|
||||
{ name: t("plan.feature.priority", "Priority Support"), included: false },
|
||||
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
name: t('plan.team.name', 'Team'),
|
||||
id: "team",
|
||||
name: t("plan.team.name", "Team"),
|
||||
price: teamPlan?.price || 10,
|
||||
currency: teamPlan?.currency || '$',
|
||||
period: t('plan.period.month', '/month'),
|
||||
currency: teamPlan?.currency || "$",
|
||||
period: t("plan.period.month", "/month"),
|
||||
popular: true,
|
||||
overagePrice: teamPlan?.overagePrice || 0.05,
|
||||
highlights: TEAM_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
|
||||
highlights: TEAM_PLAN_FEATURES.map((f) => t(f.translationKey, f.defaultText)),
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
|
||||
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
|
||||
{ name: t('plan.feature.api', 'Monthly API Credits'), included: true },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
|
||||
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
|
||||
]
|
||||
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
|
||||
{ name: t("plan.feature.fileSize", "File Size Limit"), included: true },
|
||||
{ name: t("plan.feature.automation", "Automate tool workflows"), included: true },
|
||||
{ name: t("plan.feature.api", "Monthly API Credits"), included: true },
|
||||
{ name: t("plan.feature.priority", "Priority Support"), included: false },
|
||||
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: t('plan.enterprise.name', 'Enterprise'),
|
||||
id: "enterprise",
|
||||
name: t("plan.enterprise.name", "Enterprise"),
|
||||
price: 0,
|
||||
currency: '$',
|
||||
period: '',
|
||||
currency: "$",
|
||||
period: "",
|
||||
isContactOnly: true,
|
||||
highlights: ENTERPRISE_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
|
||||
highlights: ENTERPRISE_PLAN_FEATURES.map((f) => t(f.translationKey, f.defaultText)),
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
|
||||
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
|
||||
{ name: t('plan.feature.api', 'Monthly API Credits'), included: true },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
|
||||
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: true },
|
||||
]
|
||||
}
|
||||
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
|
||||
{ name: t("plan.feature.fileSize", "File Size Limit"), included: true },
|
||||
{ name: t("plan.feature.automation", "Automate tool workflows"), included: true },
|
||||
{ name: t("plan.feature.api", "Monthly API Credits"), included: true },
|
||||
{ name: t("plan.feature.priority", "Priority Support"), included: true },
|
||||
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [t, plans]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import { useEffect } from "react";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
|
||||
/**
|
||||
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
|
||||
@@ -14,17 +14,14 @@ export function useSaveShortcut() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
// Check for Ctrl+S (Windows/Linux) or Cmd+S (Mac)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
|
||||
// Get selected files or all files if nothing selected
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const filesToSave = selectedFileIds.length > 0
|
||||
? selectors.getFiles(selectedFileIds)
|
||||
: selectors.getFiles();
|
||||
const stubsToSave = selectedFileIds.length > 0
|
||||
? selectors.getStirlingFileStubs(selectedFileIds)
|
||||
: selectors.getStirlingFileStubs();
|
||||
const filesToSave = selectedFileIds.length > 0 ? selectors.getFiles(selectedFileIds) : selectors.getFiles();
|
||||
const stubsToSave =
|
||||
selectedFileIds.length > 0 ? selectors.getStirlingFileStubs(selectedFileIds) : selectors.getStirlingFileStubs();
|
||||
|
||||
if (filesToSave.length === 0) {
|
||||
return;
|
||||
@@ -40,14 +37,14 @@ export function useSaveShortcut() {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: stub.localFilePath
|
||||
localPath: stub.localFilePath,
|
||||
});
|
||||
|
||||
// Mark file as clean after successful save
|
||||
if (result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -57,7 +54,7 @@ export function useSaveShortcut() {
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectors, state.ui.selectedFileIds, fileActions]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
export interface SelfHostedAuthState {
|
||||
isSelfHosted: boolean;
|
||||
@@ -21,13 +21,13 @@ export function useSelfHostedAuth(): SelfHostedAuthState {
|
||||
const wasSelfHosted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(mode => setIsSelfHosted(mode === 'selfhosted'));
|
||||
return connectionModeService.subscribeToModeChanges(cfg => setIsSelfHosted(cfg.mode === 'selfhosted'));
|
||||
void connectionModeService.getCurrentMode().then((mode) => setIsSelfHosted(mode === "selfhosted"));
|
||||
return connectionModeService.subscribeToModeChanges((cfg) => setIsSelfHosted(cfg.mode === "selfhosted"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void authService.isAuthenticated().then(setIsAuthenticated);
|
||||
return authService.subscribeToAuth(status => setIsAuthenticated(status === 'authenticated'));
|
||||
return authService.subscribeToAuth((status) => setIsAuthenticated(status === "authenticated"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
|
||||
/**
|
||||
* Desktop implementation of useSelfHostedToolAvailability.
|
||||
@@ -14,9 +14,7 @@ import { endpointAvailabilityService } from '@app/services/endpointAvailabilityS
|
||||
* - Self-hosted server is online
|
||||
* - Local backend port is not yet known
|
||||
*/
|
||||
export function useSelfHostedToolAvailability(
|
||||
tools: Array<{ id: string; endpoints?: string[] }>
|
||||
): Set<string> {
|
||||
export function useSelfHostedToolAvailability(tools: Array<{ id: string; endpoints?: string[] }>): Set<string> {
|
||||
const [unavailableIds, setUnavailableIds] = useState<Set<string>>(new Set());
|
||||
// Keep a stable ref to the latest tools list to avoid unnecessary re-subscriptions
|
||||
const toolsRef = useRef(tools);
|
||||
@@ -27,13 +25,13 @@ export function useSelfHostedToolAvailability(
|
||||
|
||||
const computeUnavailableTools = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== 'selfhosted') {
|
||||
if (mode !== "selfhosted") {
|
||||
setUnavailableIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status !== 'offline') {
|
||||
if (status !== "offline") {
|
||||
// Idle or checking — not yet confirmed offline; don't mark anything unavailable
|
||||
if (!cancelled) setUnavailableIds(new Set());
|
||||
return;
|
||||
@@ -49,20 +47,18 @@ export function useSelfHostedToolAvailability(
|
||||
// For each tool, check whether at least one of its endpoints is supported locally
|
||||
const unavailable = new Set<string>();
|
||||
await Promise.all(
|
||||
toolsRef.current.map(async tool => {
|
||||
toolsRef.current.map(async (tool) => {
|
||||
const endpoints = tool.endpoints ?? [];
|
||||
if (endpoints.length === 0) return; // No endpoints → always available
|
||||
|
||||
const locallySupported = await Promise.all(
|
||||
endpoints.map(ep =>
|
||||
endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl)
|
||||
)
|
||||
endpoints.map((ep) => endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl)),
|
||||
);
|
||||
|
||||
if (!locallySupported.some(Boolean)) {
|
||||
unavailable.add(tool.id);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
if (!cancelled) setUnavailableIds(unavailable);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useSelfHostedAuth } from '@app/hooks/useSelfHostedAuth';
|
||||
import type { SharingEnabledResult } from '@core/hooks/useSharingEnabled';
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSelfHostedAuth } from "@app/hooks/useSelfHostedAuth";
|
||||
import type { SharingEnabledResult } from "@core/hooks/useSharingEnabled";
|
||||
|
||||
/**
|
||||
* Desktop override: file-sharing features require self-hosted mode AND an
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { useState, useEffect } from "react";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
|
||||
/**
|
||||
* Desktop hook to check if a tool endpoint will use cloud backend
|
||||
@@ -28,7 +28,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
|
||||
|
||||
// Check if in SaaS mode
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== 'saas') {
|
||||
if (mode !== "saas") {
|
||||
setUsesCloud(false);
|
||||
return;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
|
||||
// Available on SaaS, check if also available locally
|
||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointName,
|
||||
tauriBackendService.getBackendUrl()
|
||||
tauriBackendService.getBackendUrl(),
|
||||
);
|
||||
// Show cloud badge only if SaaS supports it but local doesn't
|
||||
setUsesCloud(!supportedLocally);
|
||||
@@ -59,7 +59,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
|
||||
|
||||
// Subscribe to backend status changes to re-check when backend becomes healthy
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
if (status === "healthy") {
|
||||
checkCloudRouting();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { useViewer } from "@app/contexts/ViewerContext"
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useViewerKeyCommand(): (event: KeyboardEvent) => boolean {
|
||||
const { rotationActions } = useViewer();
|
||||
return useCallback((event:KeyboardEvent): boolean => {
|
||||
switch (event.key) {
|
||||
case 'r':
|
||||
case 'R':
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
rotationActions.rotateBackward();
|
||||
} else {
|
||||
rotationActions.rotateForward();
|
||||
}
|
||||
return useCallback(
|
||||
(event: KeyboardEvent): boolean => {
|
||||
switch (event.key) {
|
||||
case "r":
|
||||
case "R":
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
rotationActions.rotateBackward();
|
||||
} else {
|
||||
rotationActions.rotateForward();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [rotationActions]);
|
||||
}
|
||||
},
|
||||
[rotationActions],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { operationRouter } from '@app/services/operationRouter';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { useState, useEffect } from "react";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
|
||||
/**
|
||||
* Desktop hook to detect if an operation will use cloud/SaaS backend
|
||||
@@ -29,7 +29,7 @@ export function useWillUseCloud(endpoint?: string): boolean {
|
||||
const willRoute = await operationRouter.willRouteToSaaS(endpoint);
|
||||
setWillUseCloud(willRoute);
|
||||
} catch (error) {
|
||||
console.error('[useWillUseCloud] Failed to check cloud routing for endpoint:', endpoint, error);
|
||||
console.error("[useWillUseCloud] Failed to check cloud routing for endpoint:", endpoint, error);
|
||||
setWillUseCloud(false);
|
||||
}
|
||||
};
|
||||
@@ -39,7 +39,7 @@ export function useWillUseCloud(endpoint?: string): boolean {
|
||||
|
||||
// Subscribe to backend status changes to re-check when backend becomes healthy
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
if (status === "healthy") {
|
||||
checkCloudRouting();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import HomePage from '@app/pages/HomePage';
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
|
||||
/**
|
||||
* Desktop override of Landing.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Desktop override of the /login route.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
|
||||
interface LoginHeaderProps {
|
||||
title: string;
|
||||
@@ -17,11 +17,15 @@ export default function LoginHeader({ title, subtitle, centerOnly = false, onClo
|
||||
const { tooltipLogo } = useLogoAssets();
|
||||
|
||||
return (
|
||||
<div className={`login-header${centerOnly ? ' login-header-centered' : ''}`} style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flex: 1, minWidth: 0 }}>
|
||||
<div className={`login-header${centerOnly ? " login-header-centered" : ""}`} style={{ marginBottom: "2rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flex: 1, minWidth: 0 }}>
|
||||
<img src={tooltipLogo} alt="Stirling PDF" style={{ width: 36, height: 36, flexShrink: 0 }} />
|
||||
{title && <h1 className="login-title" style={{ margin: 0 }}>{title}</h1>}
|
||||
{title && (
|
||||
<h1 className="login-title" style={{ margin: 0 }}>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<ActionIcon
|
||||
@@ -29,7 +33,7 @@ export default function LoginHeader({ title, subtitle, centerOnly = false, onClo
|
||||
radius="md"
|
||||
size={32}
|
||||
variant="subtle"
|
||||
style={{ flexShrink: 0, color: 'var(--text-secondary)', outline: 'none' }}
|
||||
style={{ flexShrink: 0, color: "var(--text-secondary)", outline: "none" }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
|
||||
*/
|
||||
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { create } from '@app/services/tauriHttpClient';
|
||||
import { handleHttpError } from '@app/services/httpErrorHandler';
|
||||
import { setupApiInterceptors } from '@app/services/apiClientSetup';
|
||||
import { getApiBaseUrl } from '@app/services/apiClientConfig';
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { create } from "@app/services/tauriHttpClient";
|
||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||
import { setupApiInterceptors } from "@app/services/apiClientSetup";
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
|
||||
// Create Tauri HTTP client with default config
|
||||
const apiClient = create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
responseType: 'json',
|
||||
responseType: "json",
|
||||
withCredentials: false, // Desktop doesn't need credentials
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ apiClient.interceptors.response.use(
|
||||
async (error) => {
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---------- Exports ----------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTauri } from '@tauri-apps/api/core';
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* Desktop override: Determine base URL depending on Tauri environment
|
||||
@@ -15,7 +15,7 @@ import { isTauri } from '@tauri-apps/api/core';
|
||||
export function getApiBaseUrl(): string {
|
||||
if (!isTauri()) {
|
||||
// Runtime override to fix hardcoded localhost in builds
|
||||
if (typeof window !== 'undefined' && (window as any).STIRLING_PDF_API_BASE_URL) {
|
||||
if (typeof window !== "undefined" && (window as any).STIRLING_PDF_API_BASE_URL) {
|
||||
return (window as any).STIRLING_PDF_API_BASE_URL;
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@ export function getApiBaseUrl(): string {
|
||||
// The actual URL will be set dynamically by operationRouter based on:
|
||||
// - Offline mode: dynamic port from tauriBackendService
|
||||
// - Server mode: configured server URL from connectionModeService
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { createBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import { operationRouter } from '@app/services/operationRouter';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
|
||||
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
|
||||
import i18n from '@app/i18n';
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { createBackendNotReadyError } from "@app/constants/backendErrors";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||
let lastBackendToast = 0;
|
||||
@@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
const baseUrl = await operationRouter.getBaseUrl(originalUrl);
|
||||
|
||||
// Build the full URL
|
||||
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
|
||||
if (extendedConfig.url && !extendedConfig.url.startsWith("http")) {
|
||||
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Tag request so error handler can identify SaaS backend errors without URL matching
|
||||
extendedConfig._isSaaSRequest = isSaaSBackendRequest;
|
||||
|
||||
console.debug(`[apiClientSetup] Auth check: isRemote=${isRemote}, isSaaSBackendRequest=${isSaaSBackendRequest}, needsAuth=${needsAuth}, baseUrl=${baseUrl}`);
|
||||
console.debug(
|
||||
`[apiClientSetup] Auth check: isRemote=${isRemote}, isSaaSBackendRequest=${isSaaSBackendRequest}, needsAuth=${needsAuth}, baseUrl=${baseUrl}`,
|
||||
);
|
||||
|
||||
if (needsAuth) {
|
||||
// Enable credentials for session management
|
||||
@@ -88,7 +90,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
extendedConfig.withCredentials = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[apiClientSetup] Error in request interceptor:', error);
|
||||
console.error("[apiClientSetup] Error in request interceptor:", error);
|
||||
// Continue with request even if routing/auth logic fails
|
||||
// This ensures requests aren't blocked by interceptor errors
|
||||
}
|
||||
@@ -99,18 +101,20 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
const backendStatus = tauriBackendService.getBackendStatus();
|
||||
const backendPort = tauriBackendService.getBackendPort();
|
||||
|
||||
console.debug(`[apiClientSetup] Backend readiness check for ${extendedConfig.url}: isSaaS=${isSaaS}, skipCheck=${skipCheck}, skipForSaaSBackend=${skipForSaaSBackend}, backendHealthy=${backendHealthy}, backendStatus=${backendStatus}, backendPort=${backendPort}`);
|
||||
console.debug(
|
||||
`[apiClientSetup] Backend readiness check for ${extendedConfig.url}: isSaaS=${isSaaS}, skipCheck=${skipCheck}, skipForSaaSBackend=${skipForSaaSBackend}, backendHealthy=${backendHealthy}, backendStatus=${backendStatus}, backendPort=${backendPort}`,
|
||||
);
|
||||
|
||||
if (isSaaS && !skipCheck && !skipForSaaSBackend && !backendHealthy) {
|
||||
const method = (extendedConfig.method || 'get').toLowerCase();
|
||||
if (method !== 'get') {
|
||||
const method = (extendedConfig.method || "get").toLowerCase();
|
||||
if (method !== "get") {
|
||||
const now = Date.now();
|
||||
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||
lastBackendToast = now;
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
|
||||
alertType: "error",
|
||||
title: i18n.t("backendHealth.offline", "Backend Offline"),
|
||||
body: i18n.t("backendHealth.wait", "Please wait for the backend to finish launching and try again."),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
@@ -120,7 +124,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
return extendedConfig;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// Response interceptor: Handle auth errors and update credits from headers
|
||||
@@ -128,16 +132,18 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
async (response) => {
|
||||
// Check for credit balance update in response headers
|
||||
// Backend includes X-Credits-Remaining header after operations that consume credits
|
||||
const creditsHeader = response.headers['x-credits-remaining'];
|
||||
const creditsHeader = response.headers["x-credits-remaining"];
|
||||
|
||||
if (creditsHeader !== undefined && creditsHeader !== null) {
|
||||
const creditsRemaining = parseInt(creditsHeader, 10);
|
||||
|
||||
if (!isNaN(creditsRemaining)) {
|
||||
// Dispatch event with new balance for immediate update
|
||||
window.dispatchEvent(new CustomEvent('credits:updated', {
|
||||
detail: { creditsRemaining }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("credits:updated", {
|
||||
detail: { creditsRemaining },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +151,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config as ExtendedRequestConfig;
|
||||
const requestUrl = String(originalRequest?.url || '');
|
||||
const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
|
||||
const requestUrl = String(originalRequest?.url || "");
|
||||
const isAuthProbeRequest = requestUrl.includes("/api/v1/auth/me");
|
||||
|
||||
// Handle 401 Unauthorized - try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
@@ -155,8 +161,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
if (isAuthProbeRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
|
||||
if (typeof window !== "undefined") {
|
||||
console.warn("[apiClientSetup] 401 on path:", window.location.pathname, "url:", originalRequest.url);
|
||||
}
|
||||
if (originalRequest.skipAuthRedirect) {
|
||||
return Promise.reject(error);
|
||||
@@ -206,14 +212,14 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Handle 403 Forbidden - unauthorized access
|
||||
if (error.response?.status === 403) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('auth.accessDenied', 'Access Denied'),
|
||||
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
|
||||
alertType: "error",
|
||||
title: i18n.t("auth.accessDenied", "Access Denied"),
|
||||
body: i18n.t("auth.insufficientPermissions", "You do not have permission to perform this action."),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const TOKEN_KEY = 'stirling_jwt';
|
||||
const TOKEN_KEY = "stirling_jwt";
|
||||
|
||||
/**
|
||||
* Read auth token from any available source (Tauri store or localStorage).
|
||||
@@ -9,19 +9,19 @@ const TOKEN_KEY = 'stirling_jwt';
|
||||
export async function getAuthTokenFromAnySource(): Promise<string | null> {
|
||||
// Try Tauri store first
|
||||
try {
|
||||
const token = await invoke<string | null>('get_auth_token');
|
||||
const token = await invoke<string | null>("get_auth_token");
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthTokenStore] Failed to read from Tauri store:', error);
|
||||
console.error("[Desktop AuthTokenStore] Failed to read from Tauri store:", error);
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthTokenStore] Failed to read from localStorage:', error);
|
||||
console.error("[Desktop AuthTokenStore] Failed to read from localStorage:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import i18n from '@app/i18n';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import type { BackendHealthState } from '@app/types/backendHealth';
|
||||
import i18n from "@app/i18n";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import type { BackendHealthState } from "@app/types/backendHealth";
|
||||
|
||||
type Listener = (state: BackendHealthState) => void;
|
||||
|
||||
@@ -11,7 +11,7 @@ class BackendHealthMonitor {
|
||||
private state: BackendHealthState = {
|
||||
status: tauriBackendService.getBackendStatus(),
|
||||
error: null,
|
||||
isOnline: tauriBackendService.getBackendStatus() === 'healthy',
|
||||
isOnline: tauriBackendService.getBackendStatus() === "healthy",
|
||||
};
|
||||
|
||||
constructor(pollingInterval = 5000) {
|
||||
@@ -21,10 +21,11 @@ class BackendHealthMonitor {
|
||||
tauriBackendService.subscribeToStatus((status) => {
|
||||
this.updateState({
|
||||
status,
|
||||
error: status === 'healthy' ? null : this.state.error,
|
||||
message: status === 'healthy'
|
||||
? i18n.t('backendHealth.online', 'Backend Online')
|
||||
: this.state.message ?? i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
error: status === "healthy" ? null : this.state.error,
|
||||
message:
|
||||
status === "healthy"
|
||||
? i18n.t("backendHealth.online", "Backend Online")
|
||||
: (this.state.message ?? i18n.t("backendHealth.offline", "Backend Offline")),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -35,7 +36,7 @@ class BackendHealthMonitor {
|
||||
...this.state,
|
||||
...partial,
|
||||
status: nextStatus,
|
||||
isOnline: nextStatus === 'healthy',
|
||||
isOnline: nextStatus === "healthy",
|
||||
};
|
||||
|
||||
// Only notify listeners if meaningful state changed
|
||||
@@ -73,24 +74,24 @@ class BackendHealthMonitor {
|
||||
const healthy = await tauriBackendService.checkBackendHealth();
|
||||
if (healthy) {
|
||||
this.updateState({
|
||||
status: 'healthy',
|
||||
message: i18n.t('backendHealth.online', 'Backend Online'),
|
||||
status: "healthy",
|
||||
message: i18n.t("backendHealth.online", "Backend Online"),
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
this.updateState({
|
||||
status: 'unhealthy',
|
||||
message: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
error: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
status: "unhealthy",
|
||||
message: i18n.t("backendHealth.offline", "Backend Offline"),
|
||||
error: i18n.t("backendHealth.offline", "Backend Offline"),
|
||||
});
|
||||
}
|
||||
return healthy;
|
||||
} catch (error) {
|
||||
console.error('[BackendHealthMonitor] Health check failed:', error);
|
||||
console.error("[BackendHealthMonitor] Health check failed:", error);
|
||||
this.updateState({
|
||||
status: 'unhealthy',
|
||||
message: 'Backend is unavailable',
|
||||
error: 'Backend offline',
|
||||
status: "unhealthy",
|
||||
message: "Backend is unavailable",
|
||||
error: "Backend offline",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import i18n from '@app/i18n';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { operationRouter } from '@app/services/operationRouter';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import i18n from "@app/i18n";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
|
||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||
let lastBackendToast = 0;
|
||||
@@ -27,28 +27,25 @@ export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
|
||||
if (endpoint) {
|
||||
const skipCheck = await operationRouter.shouldSkipBackendReadyCheck(endpoint);
|
||||
if (skipCheck) {
|
||||
console.debug('[backendReadinessGuard] Skipping backend ready check (SaaS routing)');
|
||||
console.debug("[backendReadinessGuard] Skipping backend ready check (SaaS routing)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'selfhosted') {
|
||||
if (mode === "selfhosted") {
|
||||
let { status } = selfHostedServerMonitor.getSnapshot();
|
||||
|
||||
// 'checking' means the first poll hasn't returned yet. Wait briefly (up to
|
||||
// 1.5 s) for it to resolve so we don't surface raw network errors during the
|
||||
// first few seconds after launch. If it doesn't resolve in time we fall
|
||||
// through and allow the operation — the HTTP layer will handle any error.
|
||||
if (status === 'checking') {
|
||||
await Promise.race([
|
||||
selfHostedServerMonitor.checkNow(),
|
||||
new Promise<void>(resolve => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
if (status === "checking") {
|
||||
await Promise.race([selfHostedServerMonitor.checkNow(), new Promise<void>((resolve) => setTimeout(resolve, 1500))]);
|
||||
status = selfHostedServerMonitor.getSnapshot().status;
|
||||
}
|
||||
|
||||
if (status === 'offline') {
|
||||
if (status === "offline") {
|
||||
// Server offline: allow through if local backend port is known.
|
||||
// operationRouter will route to local for supported endpoints.
|
||||
// Suppress the toast — SelfHostedOfflineBanner communicates the outage.
|
||||
@@ -73,9 +70,9 @@ export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
|
||||
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||
lastBackendToast = now;
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
body: i18n.t('backendHealth.checking', 'Checking backend status...'),
|
||||
alertType: "error",
|
||||
title: i18n.t("backendHealth.offline", "Backend Offline"),
|
||||
body: i18n.t("backendHealth.checking", "Checking backend status..."),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
|
||||
export type ConnectionMode = 'saas' | 'selfhosted' | 'local';
|
||||
export type ConnectionMode = "saas" | "selfhosted" | "local";
|
||||
|
||||
export interface SSOProviderConfig {
|
||||
id: string;
|
||||
@@ -37,8 +37,8 @@ export interface ConnectionTestResult {
|
||||
diagnostics?: DiagnosticResult[];
|
||||
}
|
||||
|
||||
export const LOCAL_MODE_STORAGE_KEY = 'stirling-local-mode';
|
||||
export const JWT_EXPIRED_PROMPTED_KEY = 'stirling-jwt-expired-prompted';
|
||||
export const LOCAL_MODE_STORAGE_KEY = "stirling-local-mode";
|
||||
export const JWT_EXPIRED_PROMPTED_KEY = "stirling-jwt-expired-prompted";
|
||||
|
||||
export class ConnectionModeService {
|
||||
private static instance: ConnectionModeService;
|
||||
@@ -57,7 +57,7 @@ export class ConnectionModeService {
|
||||
if (!this.configLoadedOnce) {
|
||||
await this.loadConfig();
|
||||
}
|
||||
return this.currentConfig || { mode: 'saas', server_config: null, lock_connection_mode: false };
|
||||
return this.currentConfig || { mode: "saas", server_config: null, lock_connection_mode: false };
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<ConnectionMode> {
|
||||
@@ -79,24 +79,24 @@ export class ConnectionModeService {
|
||||
|
||||
private notifyListeners() {
|
||||
if (this.currentConfig) {
|
||||
this.modeListeners.forEach(listener => listener(this.currentConfig!));
|
||||
this.modeListeners.forEach((listener) => listener(this.currentConfig!));
|
||||
}
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const config = await invoke<ConnectionConfig>('get_connection_config');
|
||||
const config = await invoke<ConnectionConfig>("get_connection_config");
|
||||
|
||||
const localFlag = localStorage.getItem(LOCAL_MODE_STORAGE_KEY);
|
||||
|
||||
if (localFlag === 'true') {
|
||||
if (localFlag === "true") {
|
||||
// User previously chose local-only mode (signed out or explicitly went offline).
|
||||
// Applies to both 'saas' and 'selfhosted' store modes — the Rust guard on locked
|
||||
// deployments can't change 'selfhosted' to 'saas' in the store, so we check the
|
||||
// flag regardless of what the store says.
|
||||
config.mode = 'local';
|
||||
config.mode = "local";
|
||||
} else if (
|
||||
config.mode === 'saas' &&
|
||||
config.mode === "saas" &&
|
||||
config.server_config === null &&
|
||||
!config.lock_connection_mode &&
|
||||
localFlag === null
|
||||
@@ -108,59 +108,63 @@ export class ConnectionModeService {
|
||||
// MSI installs with STIRLING_SERVER_URL are excluded because they have a
|
||||
// non-null server_config; locked provisioned installs are excluded by the
|
||||
// lock_connection_mode guard.
|
||||
config.mode = 'local';
|
||||
config.mode = "local";
|
||||
}
|
||||
|
||||
this.currentConfig = config;
|
||||
this.configLoadedOnce = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load connection config:', error);
|
||||
console.error("Failed to load connection config:", error);
|
||||
// Default to local mode on error — safer than showing SaaS UI for a
|
||||
// desktop app whose bundled backend is always available.
|
||||
this.currentConfig = { mode: 'local', server_config: null, lock_connection_mode: false };
|
||||
this.currentConfig = { mode: "local", server_config: null, lock_connection_mode: false };
|
||||
this.configLoadedOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
async switchToSaaS(saasServerUrl: string): Promise<void> {
|
||||
if (this.currentConfig?.lock_connection_mode) {
|
||||
throw new Error('Connection mode is locked by provisioning');
|
||||
throw new Error("Connection mode is locked by provisioning");
|
||||
}
|
||||
|
||||
// Clear local-only flag and expiry-prompted flag when signing in
|
||||
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
|
||||
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
|
||||
|
||||
console.log('Switching to SaaS mode');
|
||||
console.log("Switching to SaaS mode");
|
||||
|
||||
const serverConfig: ServerConfig = { url: saasServerUrl };
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'saas',
|
||||
await invoke("set_connection_mode", {
|
||||
mode: "saas",
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'saas', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
|
||||
this.currentConfig = {
|
||||
mode: "saas",
|
||||
server_config: serverConfig,
|
||||
lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false,
|
||||
};
|
||||
|
||||
// Clear endpoint availability cache when mode changes
|
||||
endpointAvailabilityService.clearCache();
|
||||
console.log('Cleared endpoint availability cache due to connection mode change');
|
||||
console.log("Cleared endpoint availability cache due to connection mode change");
|
||||
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to SaaS mode successfully');
|
||||
console.log("Switched to SaaS mode successfully");
|
||||
}
|
||||
|
||||
async switchToLocal(): Promise<void> {
|
||||
console.log('Switching to local-only mode');
|
||||
console.log("Switching to local-only mode");
|
||||
|
||||
// Persist local mode preference via localStorage so no Rust enum change is needed.
|
||||
// The Rust store records this as 'saas' (same bundled-backend behaviour); we overlay
|
||||
// the 'local' distinction purely on the TypeScript side.
|
||||
localStorage.setItem(LOCAL_MODE_STORAGE_KEY, 'true');
|
||||
localStorage.setItem(LOCAL_MODE_STORAGE_KEY, "true");
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'saas',
|
||||
await invoke("set_connection_mode", {
|
||||
mode: "saas",
|
||||
serverConfig: null,
|
||||
});
|
||||
|
||||
@@ -169,7 +173,7 @@ export class ConnectionModeService {
|
||||
const isLocked = this.currentConfig?.lock_connection_mode ?? false;
|
||||
const preservedServerConfig = isLocked ? (this.currentConfig?.server_config ?? null) : null;
|
||||
|
||||
this.currentConfig = { mode: 'local', server_config: preservedServerConfig, lock_connection_mode: isLocked };
|
||||
this.currentConfig = { mode: "local", server_config: preservedServerConfig, lock_connection_mode: isLocked };
|
||||
|
||||
// Clear endpoint availability cache when mode changes
|
||||
endpointAvailabilityService.clearCache();
|
||||
@@ -182,18 +186,22 @@ export class ConnectionModeService {
|
||||
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
|
||||
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
|
||||
|
||||
console.log('Switching to self-hosted mode:', serverConfig);
|
||||
console.log("Switching to self-hosted mode:", serverConfig);
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'selfhosted',
|
||||
await invoke("set_connection_mode", {
|
||||
mode: "selfhosted",
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
|
||||
this.currentConfig = {
|
||||
mode: "selfhosted",
|
||||
server_config: serverConfig,
|
||||
lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false,
|
||||
};
|
||||
|
||||
// Clear endpoint availability cache when mode changes
|
||||
endpointAvailabilityService.clearCache();
|
||||
console.log('Cleared endpoint availability cache due to connection mode change');
|
||||
console.log("Cleared endpoint availability cache due to connection mode change");
|
||||
|
||||
// Single authoritative calling point for health monitoring — every path that
|
||||
// switches to self-hosted mode funnels through here.
|
||||
@@ -201,7 +209,7 @@ export class ConnectionModeService {
|
||||
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to self-hosted mode successfully');
|
||||
console.log("Switched to self-hosted mode successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,10 +223,10 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] - User Agent: ${navigator.userAgent}`);
|
||||
console.log(`[ConnectionModeService] - Platform: ${navigator.platform}`);
|
||||
console.log(`[ConnectionModeService] - Online: ${navigator.onLine}`);
|
||||
console.log(`[ConnectionModeService] - Connection Type: ${(navigator as any).connection?.effectiveType || 'unknown'}`);
|
||||
console.log(`[ConnectionModeService] - Connection Type: ${(navigator as any).connection?.effectiveType || "unknown"}`);
|
||||
console.log(`[ConnectionModeService] - Language: ${navigator.language}`);
|
||||
console.log(`[ConnectionModeService] - Cookies Enabled: ${navigator.cookieEnabled}`);
|
||||
console.log(`[ConnectionModeService] - Hardware Concurrency: ${navigator.hardwareConcurrency || 'unknown'} cores`);
|
||||
console.log(`[ConnectionModeService] - Hardware Concurrency: ${navigator.hardwareConcurrency || "unknown"} cores`);
|
||||
console.log(`[ConnectionModeService] - Max Touch Points: ${navigator.maxTouchPoints}`);
|
||||
|
||||
// Check for proxy environment variables
|
||||
@@ -238,10 +246,11 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] - window.location.protocol:`, window.location.protocol);
|
||||
|
||||
// Tauri v2 detection: check for __TAURI_INTERNALS__ or tauri:// protocol
|
||||
const isTauriV2 = typeof (window as any).__TAURI_INTERNALS__ !== 'undefined' ||
|
||||
window.location.protocol === 'tauri:' ||
|
||||
window.location.hostname === 'tauri.localhost';
|
||||
const isTauriV1 = typeof (window as any).__TAURI__ !== 'undefined';
|
||||
const isTauriV2 =
|
||||
typeof (window as any).__TAURI_INTERNALS__ !== "undefined" ||
|
||||
window.location.protocol === "tauri:" ||
|
||||
window.location.hostname === "tauri.localhost";
|
||||
const isTauriV1 = typeof (window as any).__TAURI__ !== "undefined";
|
||||
const isTauri = isTauriV1 || isTauriV2;
|
||||
|
||||
console.log(`[ConnectionModeService] - Running in Tauri v1: ${isTauriV1}`);
|
||||
@@ -261,22 +270,22 @@ export class ConnectionModeService {
|
||||
}
|
||||
|
||||
const diagnostics: DiagnosticResult[] = [];
|
||||
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
|
||||
const healthUrl = `${url.replace(/\/$/, "")}/api/v1/info/status`;
|
||||
const isLocal = this.isLocalAddress(url);
|
||||
const isHttpUrl = url.startsWith('http://');
|
||||
const isHttpsUrl = url.startsWith('https://');
|
||||
const isHttpUrl = url.startsWith("http://");
|
||||
const isHttpsUrl = url.startsWith("https://");
|
||||
|
||||
console.log(`[ConnectionModeService] Connection Parameters:`);
|
||||
console.log(`[ConnectionModeService] - Target URL: ${url}`);
|
||||
console.log(`[ConnectionModeService] - Health endpoint: ${healthUrl}`);
|
||||
console.log(`[ConnectionModeService] - Is local address: ${isLocal}`);
|
||||
console.log(`[ConnectionModeService] - Protocol: ${isHttpUrl ? 'HTTP' : isHttpsUrl ? 'HTTPS' : 'Unknown'}`);
|
||||
console.log(`[ConnectionModeService] - Protocol: ${isHttpUrl ? "HTTP" : isHttpsUrl ? "HTTPS" : "Unknown"}`);
|
||||
console.log(`[ConnectionModeService] ================================================================`);
|
||||
|
||||
// STAGE 1: Test the protocol they specified
|
||||
if (isHttpUrl) {
|
||||
console.log(`[ConnectionModeService] Stage 1: Testing HTTP (as specified in URL)`);
|
||||
const stage1Result = await this.testHTTP(healthUrl, 'Stage 1: HTTP (as specified)');
|
||||
const stage1Result = await this.testHTTP(healthUrl, "Stage 1: HTTP (as specified)");
|
||||
diagnostics.push(stage1Result);
|
||||
|
||||
if (stage1Result.success) {
|
||||
@@ -297,15 +306,15 @@ export class ConnectionModeService {
|
||||
|
||||
// HTTP failed, try HTTPS as fallback
|
||||
console.log(`[ConnectionModeService] Stage 2: HTTP failed, trying HTTPS`);
|
||||
const httpsUrl = healthUrl.replace('http://', 'https://');
|
||||
const stage2Result = await this.testHTTPS(httpsUrl, 'Stage 2: Trying HTTPS', false);
|
||||
const httpsUrl = healthUrl.replace("http://", "https://");
|
||||
const stage2Result = await this.testHTTPS(httpsUrl, "Stage 2: Trying HTTPS", false);
|
||||
diagnostics.push(stage2Result);
|
||||
|
||||
if (stage2Result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server is only accessible via HTTPS, not HTTP.',
|
||||
errorCode: 'HTTP_NOT_AVAILABLE',
|
||||
error: "Server is only accessible via HTTPS, not HTTP.",
|
||||
errorCode: "HTTP_NOT_AVAILABLE",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -314,7 +323,7 @@ export class ConnectionModeService {
|
||||
} else {
|
||||
// HTTPS URL or no protocol - test HTTPS
|
||||
console.log(`[ConnectionModeService] Stage 1: Testing HTTPS with full certificate validation`);
|
||||
const stage1Result = await this.testHTTPS(healthUrl, 'Stage 1: Standard HTTPS', false);
|
||||
const stage1Result = await this.testHTTPS(healthUrl, "Stage 1: Standard HTTPS", false);
|
||||
diagnostics.push(stage1Result);
|
||||
|
||||
if (stage1Result.success) {
|
||||
@@ -332,7 +341,7 @@ export class ConnectionModeService {
|
||||
|
||||
// STAGE 2: Test with certificate validation disabled (diagnose cert issues)
|
||||
console.log(`[ConnectionModeService] Stage 2: Testing HTTPS with certificate validation disabled`);
|
||||
const stage2Result = await this.testHTTPS(healthUrl, 'Stage 2: HTTPS (no cert validation)', true);
|
||||
const stage2Result = await this.testHTTPS(healthUrl, "Stage 2: HTTPS (no cert validation)", true);
|
||||
diagnostics.push(stage2Result);
|
||||
|
||||
if (stage2Result.success) {
|
||||
@@ -351,16 +360,16 @@ export class ConnectionModeService {
|
||||
|
||||
// STAGE 3: Try HTTP instead (for local/internal servers)
|
||||
console.log(`[ConnectionModeService] Stage 3: Testing HTTP instead of HTTPS`);
|
||||
const httpUrl = healthUrl.replace('https://', 'http://');
|
||||
const stage3Result = await this.testHTTP(httpUrl, 'Stage 3: HTTP (unencrypted)');
|
||||
const httpUrl = healthUrl.replace("https://", "http://");
|
||||
const stage3Result = await this.testHTTP(httpUrl, "Stage 3: HTTP (unencrypted)");
|
||||
diagnostics.push(stage3Result);
|
||||
|
||||
if (stage3Result.success) {
|
||||
console.log(`[ConnectionModeService] ⚠️ HTTP works but HTTPS doesn't`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server is only accessible via HTTP (not HTTPS).',
|
||||
errorCode: 'HTTPS_NOT_AVAILABLE',
|
||||
error: "Server is only accessible via HTTP (not HTTPS).",
|
||||
errorCode: "HTTPS_NOT_AVAILABLE",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -398,8 +407,8 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] ❌ No external connectivity - network/firewall issue`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'No internet connectivity detected. All network requests are failing.',
|
||||
errorCode: 'NETWORK_BLOCKED',
|
||||
error: "No internet connectivity detected. All network requests are failing.",
|
||||
errorCode: "NETWORK_BLOCKED",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -415,12 +424,12 @@ export class ConnectionModeService {
|
||||
const stage6Result = await this.testStage6_DNSResolution(urlObj.hostname);
|
||||
diagnostics.push(stage6Result);
|
||||
|
||||
if (!stage6Result.success && stage6Result.message.includes('DNS lookup failed')) {
|
||||
if (!stage6Result.success && stage6Result.message.includes("DNS lookup failed")) {
|
||||
console.log(`[ConnectionModeService] ❌ DNS resolution failed for target server`);
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot resolve hostname: ${urlObj.hostname}`,
|
||||
errorCode: 'DNS_RESOLUTION_FAILED',
|
||||
errorCode: "DNS_RESOLUTION_FAILED",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -434,8 +443,8 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] ⚠️ HEAD method works but GET doesn't - unusual server behavior`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server responds to HEAD requests but not GET requests.',
|
||||
errorCode: 'METHOD_MISMATCH',
|
||||
error: "Server responds to HEAD requests but not GET requests.",
|
||||
errorCode: "METHOD_MISMATCH",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -449,8 +458,8 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] ⚠️ Works with browser User-Agent - server may be blocking desktop apps`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.',
|
||||
errorCode: 'USER_AGENT_BLOCKED',
|
||||
error: "Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.",
|
||||
errorCode: "USER_AGENT_BLOCKED",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -459,20 +468,19 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] ❌ Server unreachable - all diagnostic tests failed`);
|
||||
|
||||
// Analyze timing patterns
|
||||
const avgDuration = diagnostics
|
||||
.filter(d => !d.success && d.duration)
|
||||
.reduce((sum, d) => sum + (d.duration || 0), 0) /
|
||||
diagnostics.filter(d => !d.success && d.duration).length;
|
||||
const avgDuration =
|
||||
diagnostics.filter((d) => !d.success && d.duration).reduce((sum, d) => sum + (d.duration || 0), 0) /
|
||||
diagnostics.filter((d) => !d.success && d.duration).length;
|
||||
|
||||
// Log comprehensive diagnostic summary
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
|
||||
console.log(`[ConnectionModeService] Total tests run: ${diagnostics.length}`);
|
||||
console.log(`[ConnectionModeService] Passed: ${diagnostics.filter(d => d.success).length}`);
|
||||
console.log(`[ConnectionModeService] Failed: ${diagnostics.filter(d => !d.success).length}`);
|
||||
console.log(`[ConnectionModeService] Passed: ${diagnostics.filter((d) => d.success).length}`);
|
||||
console.log(`[ConnectionModeService] Failed: ${diagnostics.filter((d) => !d.success).length}`);
|
||||
console.log(`[ConnectionModeService] Average failure time: ${avgDuration.toFixed(0)}ms`);
|
||||
console.log(`[ConnectionModeService] ---------------------------------------------------------------`);
|
||||
diagnostics.forEach((diag) => {
|
||||
const icon = diag.success ? '✅' : '❌';
|
||||
const icon = diag.success ? "✅" : "❌";
|
||||
console.log(`[ConnectionModeService] ${icon} ${diag.stage}: ${diag.message} (${diag.duration}ms)`);
|
||||
});
|
||||
console.log(`[ConnectionModeService] ================================================================`);
|
||||
@@ -480,9 +488,13 @@ export class ConnectionModeService {
|
||||
|
||||
// Log timing-based analysis
|
||||
if (avgDuration < 100) {
|
||||
console.log(`[ConnectionModeService] Analysis: Immediate rejections (<${avgDuration.toFixed(0)}ms) suggest firewall/antivirus blocking`);
|
||||
console.log(
|
||||
`[ConnectionModeService] Analysis: Immediate rejections (<${avgDuration.toFixed(0)}ms) suggest firewall/antivirus blocking`,
|
||||
);
|
||||
} else if (avgDuration > 5000) {
|
||||
console.log(`[ConnectionModeService] Analysis: Timeouts (avg ${(avgDuration/1000).toFixed(1)}s) suggest server not responding or network route blocked`);
|
||||
console.log(
|
||||
`[ConnectionModeService] Analysis: Timeouts (avg ${(avgDuration / 1000).toFixed(1)}s) suggest server not responding or network route blocked`,
|
||||
);
|
||||
} else {
|
||||
console.log(`[ConnectionModeService] Analysis: Server may be down, blocking connections, or behind a firewall`);
|
||||
}
|
||||
@@ -491,8 +503,8 @@ export class ConnectionModeService {
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot connect to server. Internet works but this specific server is unreachable.',
|
||||
errorCode: 'SERVER_UNREACHABLE',
|
||||
error: "Cannot connect to server. Internet works but this specific server is unreachable.",
|
||||
errorCode: "SERVER_UNREACHABLE",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -502,13 +514,13 @@ export class ConnectionModeService {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname.toLowerCase();
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('172.16.') ||
|
||||
hostname.endsWith('.local')
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("172.16.") ||
|
||||
hostname.endsWith(".local")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -519,10 +531,10 @@ export class ConnectionModeService {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
|
||||
console.log(`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? 'DISABLED' : 'ENABLED'}`);
|
||||
console.log(`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? "DISABLED" : "ENABLED"}`);
|
||||
|
||||
const fetchOptions: any = {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
connectTimeout: 10000,
|
||||
};
|
||||
|
||||
@@ -544,8 +556,8 @@ export class ConnectionModeService {
|
||||
stage: stageName,
|
||||
success: true,
|
||||
message: disableCertValidation
|
||||
? 'Connected successfully when certificate validation disabled'
|
||||
: 'Successfully connected with full certificate validation',
|
||||
? "Connected successfully when certificate validation disabled"
|
||||
: "Successfully connected with full certificate validation",
|
||||
duration,
|
||||
};
|
||||
}
|
||||
@@ -565,9 +577,12 @@ export class ConnectionModeService {
|
||||
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
// Log full error object structure for debugging
|
||||
if (error && typeof error === 'object') {
|
||||
if (error && typeof error === "object") {
|
||||
console.error(`[ConnectionModeService] - Error keys:`, Object.keys(error));
|
||||
console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||
console.error(
|
||||
`[ConnectionModeService] - Error object:`,
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Categorize error type
|
||||
@@ -577,20 +592,27 @@ export class ConnectionModeService {
|
||||
let detailedMessage = `Failed: ${errorMsg}`;
|
||||
|
||||
// Check for TLS version mismatch (TLS 1.0/1.1 not supported)
|
||||
if (errorLower.includes('peer is incompatible') ||
|
||||
errorLower.includes('protocol version') ||
|
||||
errorLower.includes('peerincompatible') ||
|
||||
(errorLower.includes('handshake') && (errorLower.includes('tls') || errorLower.includes('ssl')))) {
|
||||
if (
|
||||
errorLower.includes("peer is incompatible") ||
|
||||
errorLower.includes("protocol version") ||
|
||||
errorLower.includes("peerincompatible") ||
|
||||
(errorLower.includes("handshake") && (errorLower.includes("tls") || errorLower.includes("ssl")))
|
||||
) {
|
||||
detailedMessage = `TLS version not supported - Server appears to use TLS 1.0 or 1.1 (desktop app requires TLS 1.2+). Please upgrade your server's TLS configuration or use the web version.`;
|
||||
} else if (errorLower.includes('timeout') || errorLower.includes('timed out')) {
|
||||
} else if (errorLower.includes("timeout") || errorLower.includes("timed out")) {
|
||||
detailedMessage = `Timeout after ${duration}ms - server not responding`;
|
||||
} else if (errorLower.includes('certificate') || errorLower.includes('cert') || errorLower.includes('ssl') || errorLower.includes('tls')) {
|
||||
} else if (
|
||||
errorLower.includes("certificate") ||
|
||||
errorLower.includes("cert") ||
|
||||
errorLower.includes("ssl") ||
|
||||
errorLower.includes("tls")
|
||||
) {
|
||||
detailedMessage = `SSL/TLS error - ${errorMsg}`;
|
||||
} else if (errorLower.includes('connection refused') || errorLower.includes('econnrefused')) {
|
||||
} else if (errorLower.includes("connection refused") || errorLower.includes("econnrefused")) {
|
||||
detailedMessage = `Connection refused - server may not be running`;
|
||||
} else if (errorLower.includes('network') || errorLower.includes('dns') || errorLower.includes('enotfound')) {
|
||||
} else if (errorLower.includes("network") || errorLower.includes("dns") || errorLower.includes("enotfound")) {
|
||||
detailedMessage = `Network error - ${errorMsg}`;
|
||||
} else if (errorLower.includes('blocked') || errorLower.includes('filtered')) {
|
||||
} else if (errorLower.includes("blocked") || errorLower.includes("filtered")) {
|
||||
detailedMessage = `Request blocked - possible firewall/antivirus`;
|
||||
} else if (duration < 100) {
|
||||
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
|
||||
@@ -613,7 +635,7 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -624,7 +646,7 @@ export class ConnectionModeService {
|
||||
return {
|
||||
stage: stageName,
|
||||
success: true,
|
||||
message: 'Successfully connected using HTTP',
|
||||
message: "Successfully connected using HTTP",
|
||||
duration,
|
||||
};
|
||||
}
|
||||
@@ -643,8 +665,11 @@ export class ConnectionModeService {
|
||||
console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`);
|
||||
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||
if (error && typeof error === "object") {
|
||||
console.error(
|
||||
`[ConnectionModeService] - Error object:`,
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
||||
);
|
||||
}
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
@@ -652,7 +677,7 @@ export class ConnectionModeService {
|
||||
|
||||
let detailedMessage = `Failed: ${errorMsg}`;
|
||||
|
||||
if (errorLower.includes('timeout') || errorLower.includes('timed out')) {
|
||||
if (errorLower.includes("timeout") || errorLower.includes("timed out")) {
|
||||
detailedMessage = `Timeout after ${duration}ms - server not responding`;
|
||||
} else if (duration < 100) {
|
||||
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
|
||||
@@ -673,14 +698,14 @@ export class ConnectionModeService {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
connectTimeout: 30000, // 30 seconds
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 4: Extended timeout (30s)',
|
||||
stage: "Stage 4: Extended timeout (30s)",
|
||||
success: true,
|
||||
message: `Connected after ${duration}ms (slow connection)`,
|
||||
duration,
|
||||
@@ -688,16 +713,16 @@ export class ConnectionModeService {
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 4: Extended timeout (30s)',
|
||||
stage: "Stage 4: Extended timeout (30s)",
|
||||
success: false,
|
||||
message: `Server returned HTTP ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
return {
|
||||
stage: 'Stage 4: Extended timeout (30s)',
|
||||
stage: "Stage 4: Extended timeout (30s)",
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
@@ -711,25 +736,27 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] 🌐 Stage 5A: Testing external connectivity (google.com)`);
|
||||
|
||||
// Test connectivity to a reliable external service
|
||||
const response = await fetch('https://www.google.com', {
|
||||
method: 'HEAD',
|
||||
const response = await fetch("https://www.google.com", {
|
||||
method: "HEAD",
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 5A: External connectivity confirmed - HTTP ${response.status} (${duration}ms)`);
|
||||
console.log(
|
||||
`[ConnectionModeService] ✅ Stage 5A: External connectivity confirmed - HTTP ${response.status} (${duration}ms)`,
|
||||
);
|
||||
|
||||
if (response.ok || response.status === 301 || response.status === 302) {
|
||||
return {
|
||||
stage: 'Stage 5A: External (google.com)',
|
||||
stage: "Stage 5A: External (google.com)",
|
||||
success: true,
|
||||
message: 'Internet connectivity confirmed via google.com',
|
||||
message: "Internet connectivity confirmed via google.com",
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 5A: External (google.com)',
|
||||
stage: "Stage 5A: External (google.com)",
|
||||
success: false,
|
||||
message: `Unexpected response from google.com: ${response.status}`,
|
||||
duration,
|
||||
@@ -741,7 +768,7 @@ export class ConnectionModeService {
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 5A: External (google.com)',
|
||||
stage: "Stage 5A: External (google.com)",
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
@@ -754,25 +781,27 @@ export class ConnectionModeService {
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🌐 Stage 5B: Testing alternative endpoint (cloudflare.com)`);
|
||||
|
||||
const response = await fetch('https://1.1.1.1', {
|
||||
method: 'HEAD',
|
||||
const response = await fetch("https://1.1.1.1", {
|
||||
method: "HEAD",
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`);
|
||||
console.log(
|
||||
`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`,
|
||||
);
|
||||
|
||||
if (response.ok || response.status === 301 || response.status === 302 || response.status === 403) {
|
||||
return {
|
||||
stage: 'Stage 5B: External (cloudflare)',
|
||||
stage: "Stage 5B: External (cloudflare)",
|
||||
success: true,
|
||||
message: 'Alternative endpoint (1.1.1.1) reachable',
|
||||
message: "Alternative endpoint (1.1.1.1) reachable",
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 5B: External (cloudflare)',
|
||||
stage: "Stage 5B: External (cloudflare)",
|
||||
success: false,
|
||||
message: `Unexpected response: ${response.status}`,
|
||||
duration,
|
||||
@@ -784,7 +813,7 @@ export class ConnectionModeService {
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 5B: External (cloudflare)',
|
||||
stage: "Stage 5B: External (cloudflare)",
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
@@ -798,8 +827,8 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] 🌐 Stage 5C: Testing HTTP external endpoint (httpbin.org)`);
|
||||
|
||||
// Try HTTP (not HTTPS) to see if TLS/SSL is the issue
|
||||
const response = await fetch('http://httpbin.org/status/200', {
|
||||
method: 'GET',
|
||||
const response = await fetch("http://httpbin.org/status/200", {
|
||||
method: "GET",
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -808,15 +837,15 @@ export class ConnectionModeService {
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 5C: External HTTP (no TLS)',
|
||||
stage: "Stage 5C: External HTTP (no TLS)",
|
||||
success: true,
|
||||
message: 'HTTP (unencrypted) connectivity works',
|
||||
message: "HTTP (unencrypted) connectivity works",
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 5C: External HTTP (no TLS)',
|
||||
stage: "Stage 5C: External HTTP (no TLS)",
|
||||
success: false,
|
||||
message: `Unexpected response: ${response.status}`,
|
||||
duration,
|
||||
@@ -828,7 +857,7 @@ export class ConnectionModeService {
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 5C: External HTTP (no TLS)',
|
||||
stage: "Stage 5C: External HTTP (no TLS)",
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
@@ -845,7 +874,7 @@ export class ConnectionModeService {
|
||||
// If DNS fails, we'll get an immediate error
|
||||
const testUrl = `https://${hostname}`;
|
||||
await fetch(testUrl, {
|
||||
method: 'HEAD',
|
||||
method: "HEAD",
|
||||
connectTimeout: 3000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -853,7 +882,7 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] ✅ Stage 6: DNS resolved successfully (${duration}ms)`);
|
||||
|
||||
return {
|
||||
stage: 'Stage 6: DNS resolution',
|
||||
stage: "Stage 6: DNS resolution",
|
||||
success: true,
|
||||
message: `DNS resolution successful for ${hostname}`,
|
||||
duration,
|
||||
@@ -867,9 +896,9 @@ export class ConnectionModeService {
|
||||
console.error(`[ConnectionModeService] - Error:`, errorMsg);
|
||||
|
||||
// Check if it's a DNS-specific error
|
||||
if (errorLower.includes('dns') || errorLower.includes('enotfound') || errorLower.includes('getaddrinfo')) {
|
||||
if (errorLower.includes("dns") || errorLower.includes("enotfound") || errorLower.includes("getaddrinfo")) {
|
||||
return {
|
||||
stage: 'Stage 6: DNS resolution',
|
||||
stage: "Stage 6: DNS resolution",
|
||||
success: false,
|
||||
message: `DNS lookup failed - cannot resolve ${hostname}`,
|
||||
duration,
|
||||
@@ -878,7 +907,7 @@ export class ConnectionModeService {
|
||||
|
||||
// If we got here, DNS might be working but connection failed for other reasons
|
||||
return {
|
||||
stage: 'Stage 6: DNS resolution',
|
||||
stage: "Stage 6: DNS resolution",
|
||||
success: false,
|
||||
message: `DNS test inconclusive: ${errorMsg}`,
|
||||
duration,
|
||||
@@ -892,7 +921,7 @@ export class ConnectionModeService {
|
||||
console.log(`[ConnectionModeService] 🔗 Stage 7: Testing with HEAD method`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
method: "HEAD",
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -901,15 +930,15 @@ export class ConnectionModeService {
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 7: HEAD method',
|
||||
stage: "Stage 7: HEAD method",
|
||||
success: true,
|
||||
message: 'HEAD method works (GET does not)',
|
||||
message: "HEAD method works (GET does not)",
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 7: HEAD method',
|
||||
stage: "Stage 7: HEAD method",
|
||||
success: false,
|
||||
message: `HEAD method returned ${response.status}`,
|
||||
duration,
|
||||
@@ -920,7 +949,7 @@ export class ConnectionModeService {
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 7: HEAD method',
|
||||
stage: "Stage 7: HEAD method",
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
@@ -935,10 +964,11 @@ export class ConnectionModeService {
|
||||
|
||||
// Try with a standard browser User-Agent instead of Tauri's default
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
connectTimeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
},
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -947,15 +977,15 @@ export class ConnectionModeService {
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 8: Browser User-Agent',
|
||||
stage: "Stage 8: Browser User-Agent",
|
||||
success: true,
|
||||
message: 'Works with browser User-Agent (blocked with desktop UA)',
|
||||
message: "Works with browser User-Agent (blocked with desktop UA)",
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 8: Browser User-Agent',
|
||||
stage: "Stage 8: Browser User-Agent",
|
||||
success: false,
|
||||
message: `Browser UA returned ${response.status}`,
|
||||
duration,
|
||||
@@ -966,7 +996,7 @@ export class ConnectionModeService {
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 8: Browser User-Agent',
|
||||
stage: "Stage 8: Browser User-Agent",
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
@@ -974,13 +1004,12 @@ export class ConnectionModeService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async isFirstLaunch(): Promise<boolean> {
|
||||
try {
|
||||
const result = await invoke<boolean>('is_first_launch');
|
||||
const result = await invoke<boolean>("is_first_launch");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to check first launch:', error);
|
||||
console.error("Failed to check first launch:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -990,10 +1019,10 @@ export class ConnectionModeService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke('reset_setup_completion');
|
||||
console.log('Setup completion flag reset successfully');
|
||||
await invoke("reset_setup_completion");
|
||||
console.log("Setup completion flag reset successfully");
|
||||
} catch (error) {
|
||||
console.error('Failed to reset setup completion:', error);
|
||||
console.error("Failed to reset setup completion:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user