mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-16 23:08:38 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
10
frontend/src/core/hooks/useGroupSigningEnabled.ts
Normal file
10
frontend/src/core/hooks/useGroupSigningEnabled.ts
Normal 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;
|
||||
}
|
||||
18
frontend/src/core/hooks/useSharingEnabled.ts
Normal file
18
frontend/src/core/hooks/useSharingEnabled.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
12
frontend/src/desktop/hooks/useGroupSigningEnabled.ts
Normal file
12
frontend/src/desktop/hooks/useGroupSigningEnabled.ts
Normal 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;
|
||||
}
|
||||
41
frontend/src/desktop/hooks/useSelfHostedAuth.ts
Normal file
41
frontend/src/desktop/hooks/useSelfHostedAuth.ts
Normal 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 };
|
||||
}
|
||||
18
frontend/src/desktop/hooks/useSharingEnabled.ts
Normal file
18
frontend/src/desktop/hooks/useSharingEnabled.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user