mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +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 ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
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 { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||||
import { requestStartTour } from '@app/constants/events';
|
import { requestStartTour } from '@app/constants/events';
|
||||||
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
|
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
|
||||||
@@ -77,9 +79,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
const accessButtonRef = useRef<HTMLDivElement>(null);
|
const accessButtonRef = useRef<HTMLDivElement>(null);
|
||||||
const accessPopoverRef = useRef<HTMLDivElement>(null);
|
const accessPopoverRef = useRef<HTMLDivElement>(null);
|
||||||
const [accessPopoverPosition, setAccessPopoverPosition] = useState({ top: 160, left: 84 });
|
const [accessPopoverPosition, setAccessPopoverPosition] = useState({ top: 160, left: 84 });
|
||||||
const sharingEnabled = config?.storageSharingEnabled === true;
|
const { sharingEnabled, shareLinksEnabled } = useSharingEnabled();
|
||||||
const shareLinksEnabled = config?.storageShareLinksEnabled === true;
|
const groupSigningEnabled = useGroupSigningEnabled();
|
||||||
const groupSigningEnabled = config?.storageGroupSigningEnabled === true;
|
|
||||||
const isSignWorkbenchActive =
|
const isSignWorkbenchActive =
|
||||||
currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE ||
|
currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE ||
|
||||||
currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE;
|
currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE;
|
||||||
|
|||||||
@@ -335,9 +335,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
|||||||
formData.append('workflowMetadata', workflowMetadata);
|
formData.append('workflowMetadata', workflowMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.post('/api/v1/security/cert-sign/sessions', formData, {
|
await apiClient.post('/api/v1/security/cert-sign/sessions', formData);
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
|
|
||||||
alert({
|
alert({
|
||||||
alertType: 'success',
|
alertType: 'success',
|
||||||
@@ -493,9 +491,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
|||||||
|
|
||||||
// Action handlers
|
// Action handlers
|
||||||
const handleSign = async (sessionId: string, certificateData: FormData) => {
|
const handleSign = async (sessionId: string, certificateData: FormData) => {
|
||||||
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData, {
|
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData);
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
alert({
|
alert({
|
||||||
alertType: 'success',
|
alertType: 'success',
|
||||||
title: t('success'),
|
title: t('success'),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
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
|
// Convert Headers to plain object
|
||||||
const responseHeaders: Record<string, string> = {};
|
const responseHeaders: Record<string, string> = {};
|
||||||
response.headers.forEach((value, key) => {
|
response.headers.forEach((value, key) => {
|
||||||
responseHeaders[key] = value;
|
responseHeaders[key] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpResponse: TauriHttpResponse<T> = {
|
// Check for HTTP errors BEFORE reading the body so that a plain-text error
|
||||||
data,
|
// response from the server doesn't cause a JSON parse failure that gets
|
||||||
status: response.status,
|
// misreported as ERR_NETWORK.
|
||||||
statusText: response.statusText || '',
|
|
||||||
headers: responseHeaders,
|
|
||||||
config: finalConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for HTTP errors
|
|
||||||
if (!response.ok) {
|
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
|
// 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';
|
let errorCode = 'ERR_BAD_REQUEST';
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -284,13 +262,22 @@ class TauriHttpClient {
|
|||||||
method,
|
method,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
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(
|
const error = this.createError(
|
||||||
errorMessage,
|
errorMessage,
|
||||||
finalConfig,
|
finalConfig,
|
||||||
errorCode,
|
errorCode,
|
||||||
httpResponse
|
errorResponse
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run error interceptors
|
// Run error interceptors
|
||||||
@@ -307,6 +294,39 @@ class TauriHttpClient {
|
|||||||
throw finalError;
|
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
|
// Run response interceptors
|
||||||
let finalResponse = httpResponse;
|
let finalResponse = httpResponse;
|
||||||
for (const handler of this.interceptors.response.handlers) {
|
for (const handler of this.interceptors.response.handlers) {
|
||||||
|
|||||||
Reference in New Issue
Block a user