From 1e97a32d4bd1793c7f89f5c1776bb58275c3bfc8 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:37:45 +0100 Subject: [PATCH] feat(desktop): gate shared signing behind self-hosted auth (#6002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds full desktop (Tauri) support for the shared signing feature when connected to a self-hosted server, and fixes several bugs discovered during that work. ### Feature gating Shared signing, file sharing, and share links are proprietary server features that require an authenticated self-hosted session. Previously these were read directly from `config` with no awareness of connection mode or auth state, meaning the UI could appear in SaaS/local mode or when logged out. - Introduce `useGroupSigningEnabled` and `useSharingEnabled` hooks with core implementations (web behaviour unchanged) and desktop overrides that require `selfhosted` mode + an active authenticated session - Extract shared subscription logic into `useSelfHostedAuth` (connection mode + auth state + config refetch) - `QuickAccessBar` now derives all three flags from the hooks instead of raw config ### Config timing fix When a user logs in via the SetupWizard, the `jwt-available` event fires a config fetch *before* the mode is switched to `selfhosted`. This meant the config was fetched from the local bundled backend (port ~59567) which has no knowledge of `storageGroupSigningEnabled`, causing the group signing button to stay hidden until a full page refresh. `useSelfHostedAuth` detects the mode transition and triggers a fresh config fetch at the correct moment, after the self-hosted URL is active. ### Bug fixes **`SignPopout.tsx`** — Manually setting `Content-Type: multipart/form-data` on two `FormData` POST requests stripped the auto-generated boundary, causing a `400 bad multipart` from the server. Removed the explicit headers so Axios sets them correctly. **`tauriHttpClient.ts`** — `response.json()` was called before `response.ok` was checked. A plain-text error body from the server (e.g. `"Cannot sign..."`) caused a `SyntaxError` that fell into the network error catch block and was reported as `ERR_NETWORK`, hiding the real failure. The fix checks `response.ok` first, reads error bodies as text, and handles empty 200 bodies (returning `null` instead of throwing). --- ## Testing ### Prerequisites - Desktop app running in self-hosted mode pointed at a local Stirling-PDF instance (`http://localhost:8080`) - The self-hosted instance has group signing and storage enabled in settings - At least two user accounts on the self-hosted instance ### 1. Feature gating — group signing button | Step | Expected | |---|---| | Open the desktop app in **local mode** (no server configured) | Group signing button absent from QuickAccessBar | | Switch to self-hosted mode but **do not log in** | Group signing button absent | | Log in to the self-hosted server | Group signing button appears without requiring a page refresh | | Log out | Group signing button disappears immediately | | Log back in | Group signing button reappears without a page refresh | ### 2. Feature gating — file sharing Repeat the same steps above, verifying the share and share-link buttons in the file manager follow the same visibility rules. ### 3. Create a signing session 1. Log in, open the group signing panel from QuickAccessBar 2. Select a PDF, add a participant, configure signature defaults and submit 3. Verify the session is created successfully (no `400 bad multipart` error) ### 4. Participant signing 1. As the invited participant, open the signing request from QuickAccessBar 2. Upload or draw a signature and submit 3. Verify signing completes successfully (no `ERR_NETWORK` error) ### 5. Error surfacing 1. Attempt an action that the server rejects (e.g. sign a document with an invalid certificate) 2. Verify the actual server error message is shown rather than a generic network error --- .../core/components/shared/QuickAccessBar.tsx | 7 +- .../components/shared/signing/SignPopout.tsx | 8 +- .../src/core/hooks/useGroupSigningEnabled.ts | 10 +++ frontend/src/core/hooks/useSharingEnabled.ts | 18 ++++ .../desktop/hooks/useGroupSigningEnabled.ts | 12 +++ .../src/desktop/hooks/useSelfHostedAuth.ts | 41 +++++++++ .../src/desktop/hooks/useSharingEnabled.ts | 18 ++++ .../src/desktop/services/tauriHttpClient.ts | 90 +++++++++++-------- 8 files changed, 160 insertions(+), 44 deletions(-) create mode 100644 frontend/src/core/hooks/useGroupSigningEnabled.ts create mode 100644 frontend/src/core/hooks/useSharingEnabled.ts create mode 100644 frontend/src/desktop/hooks/useGroupSigningEnabled.ts create mode 100644 frontend/src/desktop/hooks/useSelfHostedAuth.ts create mode 100644 frontend/src/desktop/hooks/useSharingEnabled.ts 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) {