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 14:37:45 +00:00
committed by GitHub
parent 4a6b426651
commit 1e97a32d4b
8 changed files with 160 additions and 44 deletions
@@ -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'),