diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index ffbf1397a7..726ed19c43 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -19,6 +19,8 @@ import AllToolsNavButton from '@app/components/shared/AllToolsNavButton'; import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton"; import AppConfigModal from '@app/components/shared/AppConfigModal'; import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useGroupSigningEnabled } from '@app/hooks/useGroupSigningEnabled'; +import { useSharingEnabled } from '@app/hooks/useSharingEnabled'; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; import { requestStartTour } from '@app/constants/events'; import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton'; @@ -77,9 +79,8 @@ const QuickAccessBar = forwardRef((_, ref) => { const accessButtonRef = useRef(null); const accessPopoverRef = useRef(null); const [accessPopoverPosition, setAccessPopoverPosition] = useState({ top: 160, left: 84 }); - const sharingEnabled = config?.storageSharingEnabled === true; - const shareLinksEnabled = config?.storageShareLinksEnabled === true; - const groupSigningEnabled = config?.storageGroupSigningEnabled === true; + const { sharingEnabled, shareLinksEnabled } = useSharingEnabled(); + const groupSigningEnabled = useGroupSigningEnabled(); const isSignWorkbenchActive = currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; diff --git a/frontend/src/core/components/shared/signing/SignPopout.tsx b/frontend/src/core/components/shared/signing/SignPopout.tsx index 183f731831..8179638b08 100644 --- a/frontend/src/core/components/shared/signing/SignPopout.tsx +++ b/frontend/src/core/components/shared/signing/SignPopout.tsx @@ -335,9 +335,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: formData.append('workflowMetadata', workflowMetadata); } - await apiClient.post('/api/v1/security/cert-sign/sessions', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + await apiClient.post('/api/v1/security/cert-sign/sessions', formData); alert({ alertType: 'success', @@ -493,9 +491,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: // Action handlers const handleSign = async (sessionId: string, certificateData: FormData) => { - await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData); alert({ alertType: 'success', title: t('success'), diff --git a/frontend/src/core/hooks/useGroupSigningEnabled.ts b/frontend/src/core/hooks/useGroupSigningEnabled.ts new file mode 100644 index 0000000000..42e5fdf5f8 --- /dev/null +++ b/frontend/src/core/hooks/useGroupSigningEnabled.ts @@ -0,0 +1,10 @@ +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +/** + * Returns whether the shared (group) signing feature is available. + * Core implementation reads directly from server config. + */ +export function useGroupSigningEnabled(): boolean { + const { config } = useAppConfig(); + return config?.storageGroupSigningEnabled === true; +} diff --git a/frontend/src/core/hooks/useSharingEnabled.ts b/frontend/src/core/hooks/useSharingEnabled.ts new file mode 100644 index 0000000000..7a01b58ca6 --- /dev/null +++ b/frontend/src/core/hooks/useSharingEnabled.ts @@ -0,0 +1,18 @@ +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +export interface SharingEnabledResult { + sharingEnabled: boolean; + shareLinksEnabled: boolean; +} + +/** + * Returns whether file-sharing features are available. + * Core implementation reads directly from server config. + */ +export function useSharingEnabled(): SharingEnabledResult { + const { config } = useAppConfig(); + return { + sharingEnabled: config?.storageSharingEnabled === true, + shareLinksEnabled: config?.storageShareLinksEnabled === true, + }; +} diff --git a/frontend/src/desktop/hooks/useGroupSigningEnabled.ts b/frontend/src/desktop/hooks/useGroupSigningEnabled.ts new file mode 100644 index 0000000000..6a6da3ac05 --- /dev/null +++ b/frontend/src/desktop/hooks/useGroupSigningEnabled.ts @@ -0,0 +1,12 @@ +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useSelfHostedAuth } from '@app/hooks/useSelfHostedAuth'; + +/** + * Desktop override: shared (group) signing requires self-hosted mode AND + * an authenticated session. Returns false in SaaS/local mode or when logged out. + */ +export function useGroupSigningEnabled(): boolean { + const { config } = useAppConfig(); + const { isSelfHosted, isAuthenticated } = useSelfHostedAuth(); + return isSelfHosted && isAuthenticated && config?.storageGroupSigningEnabled === true; +} diff --git a/frontend/src/desktop/hooks/useSelfHostedAuth.ts b/frontend/src/desktop/hooks/useSelfHostedAuth.ts new file mode 100644 index 0000000000..30b716dedb --- /dev/null +++ b/frontend/src/desktop/hooks/useSelfHostedAuth.ts @@ -0,0 +1,41 @@ +import { useState, useEffect, useRef } from 'react'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { authService } from '@app/services/authService'; +import { connectionModeService } from '@app/services/connectionModeService'; + +export interface SelfHostedAuthState { + isSelfHosted: boolean; + isAuthenticated: boolean; +} + +/** + * Tracks whether the desktop app is in self-hosted mode with an active + * authenticated session. Refetches app config when the mode first transitions + * to selfhosted, since the jwt-available config fetch fires against the local + * bundled backend before the SetupWizard has switched the mode. + */ +export function useSelfHostedAuth(): SelfHostedAuthState { + const { refetch } = useAppConfig(); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isSelfHosted, setIsSelfHosted] = useState(false); + const wasSelfHosted = useRef(false); + + useEffect(() => { + void connectionModeService.getCurrentMode().then(mode => setIsSelfHosted(mode === 'selfhosted')); + return connectionModeService.subscribeToModeChanges(cfg => setIsSelfHosted(cfg.mode === 'selfhosted')); + }, []); + + useEffect(() => { + void authService.isAuthenticated().then(setIsAuthenticated); + return authService.subscribeToAuth(status => setIsAuthenticated(status === 'authenticated')); + }, []); + + useEffect(() => { + if (isSelfHosted && !wasSelfHosted.current) { + void refetch(); + } + wasSelfHosted.current = isSelfHosted; + }, [isSelfHosted, refetch]); + + return { isSelfHosted, isAuthenticated }; +} diff --git a/frontend/src/desktop/hooks/useSharingEnabled.ts b/frontend/src/desktop/hooks/useSharingEnabled.ts new file mode 100644 index 0000000000..bab59a771f --- /dev/null +++ b/frontend/src/desktop/hooks/useSharingEnabled.ts @@ -0,0 +1,18 @@ +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useSelfHostedAuth } from '@app/hooks/useSelfHostedAuth'; +import type { SharingEnabledResult } from '@core/hooks/useSharingEnabled'; + +/** + * Desktop override: file-sharing features require self-hosted mode AND an + * authenticated session. Returns false for both in SaaS/local mode or when + * logged out. + */ +export function useSharingEnabled(): SharingEnabledResult { + const { config } = useAppConfig(); + const { isSelfHosted, isAuthenticated } = useSelfHostedAuth(); + const allowed = isSelfHosted && isAuthenticated; + return { + sharingEnabled: allowed && config?.storageSharingEnabled === true, + shareLinksEnabled: allowed && config?.storageShareLinksEnabled === true, + }; +} diff --git a/frontend/src/desktop/services/tauriHttpClient.ts b/frontend/src/desktop/services/tauriHttpClient.ts index 14914cf238..43b2cd45bc 100644 --- a/frontend/src/desktop/services/tauriHttpClient.ts +++ b/frontend/src/desktop/services/tauriHttpClient.ts @@ -218,48 +218,26 @@ class TauriHttpClient { const response = await fetch(url, fetchOptions); - // Parse response based on responseType - let data: T; - const responseType = finalConfig.responseType || 'json'; - - if (responseType === 'json') { - data = await response.json() as T; - } else if (responseType === 'text') { - data = (await response.text()) as T; - } else if (responseType === 'blob') { - // Standard fetch doesn't set blob.type from Content-Type header (unlike axios) - // Set it manually to match axios behavior - const blob = await response.blob(); - if (!blob.type) { - const contentType = response.headers.get('content-type') || 'application/octet-stream'; - data = new Blob([blob], { type: contentType }) as T; - } else { - data = blob as T; - } - } else if (responseType === 'arraybuffer') { - data = (await response.arrayBuffer()) as T; - } else { - data = await response.json() as T; - } - // Convert Headers to plain object const responseHeaders: Record = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); - const httpResponse: TauriHttpResponse = { - data, - status: response.status, - statusText: response.statusText || '', - headers: responseHeaders, - config: finalConfig, - }; - - // Check for HTTP errors + // Check for HTTP errors BEFORE reading the body so that a plain-text error + // response from the server doesn't cause a JSON parse failure that gets + // misreported as ERR_NETWORK. if (!response.ok) { + // Read the body as text to surface the real server error message + let errorBody: string; + try { + errorBody = await response.text(); + } catch { + errorBody = ''; + } + // Create more descriptive error messages based on status code - let errorMessage = `Request failed with status code ${response.status}`; + let errorMessage = errorBody || `Request failed with status code ${response.status}`; let errorCode = 'ERR_BAD_REQUEST'; if (response.status === 401) { @@ -284,13 +262,22 @@ class TauriHttpClient { method, status: response.status, statusText: response.statusText, + body: errorBody, }); + const errorResponse: TauriHttpResponse = { + data: errorBody as T, + status: response.status, + statusText: response.statusText || '', + headers: responseHeaders, + config: finalConfig, + }; + const error = this.createError( errorMessage, finalConfig, errorCode, - httpResponse + errorResponse ); // Run error interceptors @@ -307,6 +294,39 @@ class TauriHttpClient { throw finalError; } + // Parse response body for successful responses + let data: T; + const responseType = finalConfig.responseType || 'json'; + + if (responseType === 'json') { + const text = await response.text(); + data = (text ? JSON.parse(text) : null) as T; + } else if (responseType === 'text') { + data = (await response.text()) as T; + } else if (responseType === 'blob') { + // Standard fetch doesn't set blob.type from Content-Type header (unlike axios) + // Set it manually to match axios behavior + const blob = await response.blob(); + if (!blob.type) { + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + data = new Blob([blob], { type: contentType }) as T; + } else { + data = blob as T; + } + } else if (responseType === 'arraybuffer') { + data = (await response.arrayBuffer()) as T; + } else { + data = await response.json() as T; + } + + const httpResponse: TauriHttpResponse = { + data, + status: response.status, + statusText: response.statusText || '', + headers: responseHeaders, + config: finalConfig, + }; + // Run response interceptors let finalResponse = httpResponse; for (const handler of this.interceptors.response.handlers) {