feat(desktop): gate shared signing behind self-hosted auth (#6002)

## 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
This commit is contained in:
ConnorYoh
2026-03-30 15:37:45 +01:00
committed by GitHub
parent 4a6b426651
commit 1e97a32d4b
8 changed files with 160 additions and 44 deletions

View File

@@ -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<HTMLDivElement>((_, ref) => {
const accessButtonRef = useRef<HTMLDivElement>(null);
const accessPopoverRef = useRef<HTMLDivElement>(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;

View File

@@ -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'),

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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,
};
}

View File

@@ -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<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const httpResponse: TauriHttpResponse<T> = {
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<T> = {
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<T> = {
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) {