Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2026-03-25 11:00:40 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh EthanHealy01 Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 47cad0a131
commit 28613caf8a
181 changed files with 25715 additions and 124 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import ShareLinkPage from "@app/routes/ShareLinkPage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
@@ -59,7 +60,7 @@ export default function App() {
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
@@ -15,6 +15,7 @@ import AdminFeaturesSection from '@app/components/shared/config/configSections/A
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
import AdminStorageSharingSection from '@app/components/shared/config/configSections/AdminStorageSharingSection';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
import AccountSection from '@app/components/shared/config/configSections/AccountSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
@@ -98,6 +99,14 @@ export const useConfigNavSections = (
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
},
{
key: 'adminStorageSharing',
label: t('settings.configuration.storageSharing', 'File Storage & Sharing'),
icon: 'storage-rounded',
component: <AdminStorageSharingSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
},
{
key: 'adminEndpoints',
label: t('settings.configuration.endpoints', 'Endpoints'),
@@ -0,0 +1,294 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Anchor, Group, Loader, Paper, Stack, Switch, Text } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
interface StorageSharingSettingsData {
enabled?: boolean;
sharing?: {
enabled?: boolean;
linkEnabled?: boolean;
emailEnabled?: boolean;
};
signing?: {
enabled?: boolean;
};
system?: {
frontendUrl?: string;
};
mail?: {
enabled?: boolean;
};
}
export default function AdminStorageSharingSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<StorageSharingSettingsData>({
sectionName: 'storage',
fetchTransformer: async () => {
const [storageResponse, systemResponse, mailResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/storage'),
apiClient.get('/api/v1/admin/settings/section/system'),
apiClient.get('/api/v1/admin/settings/section/mail'),
]);
const storageData = storageResponse.data || {};
const systemData = systemResponse.data || {};
const mailData = mailResponse.data || {};
return {
...storageData,
system: { frontendUrl: systemData.frontendUrl || '' },
mail: { enabled: mailData.enabled || false },
};
},
saveTransformer: (currentSettings) => ({
sectionData: {
enabled: currentSettings.enabled,
sharing: {
enabled: currentSettings.sharing?.enabled,
linkEnabled: currentSettings.sharing?.linkEnabled,
emailEnabled: currentSettings.sharing?.emailEnabled,
},
signing: {
enabled: currentSettings.signing?.enabled,
},
},
}),
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const storageEnabled = settings.enabled ?? false;
const sharingEnabled = storageEnabled && (settings.sharing?.enabled ?? false);
const frontendUrlConfigured = Boolean(settings.system?.frontendUrl?.trim());
const mailEnabled = Boolean(settings.mail?.enabled);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loginEnabled && loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<div className="settings-section-content">
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.storage.title', 'File Storage & Sharing')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.storage.description', 'Control server storage and sharing options.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.enabled.label', 'Enable Server File Storage')}</Text>
{isFieldPending('enabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.enabled.description', 'Allow users to store files on the server.')}
</Text>
<Switch
checked={storageEnabled}
onChange={(e) => setSettings({ ...settings, enabled: e.currentTarget.checked })}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.sharing.enabled.label', 'Enable Sharing')}</Text>
{isFieldPending('sharing.enabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.sharing.enabled.description', 'Allow users to share stored files.')}
</Text>
<Switch
checked={settings.sharing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, enabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
/>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.sharing.links.label', 'Enable Share Links')}</Text>
{isFieldPending('sharing.linkEnabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.sharing.links.description', 'Allow sharing via signed-in links.')}
</Text>
{!frontendUrlConfigured && (
<Text size="xs" c="orange">
{t('admin.settings.storage.sharing.links.frontendUrlNote', 'Requires a Frontend URL. ')}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate('/settings/adminGeneral#frontendUrl');
}}
c="orange"
td="underline"
>
{t('admin.settings.storage.sharing.links.frontendUrlLink', 'Configure in System Settings')}
</Anchor>
</Text>
)}
<Switch
checked={settings.sharing?.linkEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, linkEnabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !sharingEnabled || !frontendUrlConfigured}
styles={getDisabledStyles()}
/>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.sharing.email.label', 'Enable Email Sharing')}</Text>
{isFieldPending('sharing.emailEnabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.sharing.email.description', 'Allow sharing with email addresses.')}
</Text>
{!mailEnabled && (
<Text size="xs" c="orange">
{t('admin.settings.storage.sharing.email.mailNote', 'Requires mail configuration. ')}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate('/settings/adminConnections');
}}
c="orange"
td="underline"
>
{t('admin.settings.storage.sharing.email.mailLink', 'Configure Mail Settings')}
</Anchor>
</Text>
)}
<Switch
checked={settings.sharing?.emailEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, emailEnabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !sharingEnabled || !mailEnabled}
styles={getDisabledStyles()}
/>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.signing.enabled.label', 'Enable Group Signing (Alpha)')}</Text>
{isFieldPending('signing.enabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.signing.enabled.description', 'Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled.')}
</Text>
<Switch
checked={settings.signing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
signing: { ...settings.signing, enabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
/>
</Stack>
</Paper>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
</div>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,279 @@
import React, { useState } from 'react';
import {
Stack,
Card,
Text,
Badge,
Group,
Button,
Loader,
Alert,
TextInput,
FileInput,
Select,
} from '@mantine/core';
import { useParticipantSession } from '@app/hooks/workflow/useParticipantSession';
import InfoIcon from '@mui/icons-material/Info';
import DownloadIcon from '@mui/icons-material/Download';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
interface ParticipantViewProps {
token: string;
}
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const { session, participant, loading, error, submitSignature, decline, downloadDocument } =
useParticipantSession(token);
const [certType, setCertType] = useState<string>('P12');
const [password, setPassword] = useState<string>('');
const [certFile, setCertFile] = useState<File | null>(null);
const [location, setLocation] = useState<string>('');
const [reason, setReason] = useState<string>('Document Signing');
const [showSignature, _setShowSignature] = useState<boolean>(true);
const [pageNumber, setPageNumber] = useState<number>(1);
const [declineReason, _setDeclineReason] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const handleSubmitSignature = async () => {
if (!certFile && certType !== 'SERVER') {
setNotification({ type: 'error', message: 'Please select a certificate file' });
return;
}
setIsSubmitting(true);
setNotification(null);
try {
await submitSignature({
participantToken: token,
certType,
password,
p12File: certType === 'P12' ? certFile || undefined : undefined,
jksFile: certType === 'JKS' ? certFile || undefined : undefined,
showSignature,
pageNumber,
location,
reason,
showLogo: true,
});
setNotification({ type: 'success', message: 'Signature submitted successfully!' });
} catch (err: any) {
setNotification({ type: 'error', message: `Failed to submit signature: ${err.message}` });
} finally {
setIsSubmitting(false);
}
};
const handleDecline = async () => {
if (window.confirm('Are you sure you want to decline signing this document?')) {
setNotification(null);
try {
await decline(token, declineReason || 'Declined by participant');
setNotification({ type: 'success', message: 'You have declined this signing request.' });
} catch (err: any) {
setNotification({ type: 'error', message: `Failed to decline: ${err.message}` });
}
}
};
if (loading && !session) {
return (
<Stack align="center" justify="center" p="xl">
<Loader size="lg" />
<Text c="dimmed">Loading session...</Text>
</Stack>
);
}
if (error) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error">
{error}
</Alert>
);
}
if (!session || !participant) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
Session not found or access denied.
</Alert>
);
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'SIGNED':
return <Badge color="green">Signed</Badge>;
case 'DECLINED':
return <Badge color="red">Declined</Badge>;
case 'VIEWED':
return <Badge color="blue">Viewed</Badge>;
case 'NOTIFIED':
return <Badge color="yellow">Notified</Badge>;
case 'PENDING':
return <Badge color="gray">Pending</Badge>;
default:
return <Badge>{status}</Badge>;
}
};
const canSign = !participant.hasCompleted && !participant.isExpired && session.status === 'IN_PROGRESS';
return (
<Stack gap="md">
{notification && (
<Alert
icon={notification.type === 'success' ? <CheckCircleIcon fontSize="small" /> : <InfoIcon fontSize="small" />}
color={notification.type === 'success' ? 'green' : 'red'}
withCloseButton
onClose={() => setNotification(null)}
>
{notification.message}
</Alert>
)}
<Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<div>
<Text fw={500} size="lg">
{session.documentName}
</Text>
<Text size="sm" c="dimmed">
From: {session.ownerUsername}
</Text>
</div>
{getStatusBadge(participant.status)}
</Group>
{session.message && (
<Alert icon={<InfoIcon fontSize="small" />} color="blue" variant="light">
<Text size="sm">{session.message}</Text>
</Alert>
)}
{session.dueDate && (
<Text size="sm" c="dimmed">
Due Date: {session.dueDate}
</Text>
)}
<Group gap="xs" mt="sm">
<Button
size="sm"
leftSection={<DownloadIcon fontSize="small" />}
onClick={() => downloadDocument(token)}
variant="light"
>
Download Document
</Button>
</Group>
</Stack>
</Card>
{canSign && (
<Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="md">
<Text fw={500} size="lg">
Sign Document
</Text>
<Select
label="Certificate Type"
value={certType}
onChange={(value) => setCertType(value || 'P12')}
data={[
{ value: 'P12', label: 'P12/PKCS12 Certificate' },
{ value: 'JKS', label: 'JKS Keystore' },
{ value: 'SERVER', label: 'Server Certificate (if available)' },
]}
size="sm"
/>
{certType !== 'SERVER' && (
<>
<FileInput
label="Certificate File"
placeholder="Select certificate file"
value={certFile}
onChange={setCertFile}
accept=".p12,.pfx,.jks"
size="sm"
/>
<TextInput
label="Certificate Password"
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
size="sm"
/>
</>
)}
<TextInput
label="Location"
placeholder="e.g., San Francisco, CA"
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Reason"
placeholder="e.g., Document approval"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Page Number (optional)"
type="number"
value={pageNumber}
onChange={(e) => setPageNumber(parseInt(e.currentTarget.value) || 1)}
size="sm"
min={1}
/>
<Group gap="xs">
<Button
leftSection={<CheckCircleIcon fontSize="small" />}
onClick={handleSubmitSignature}
loading={isSubmitting}
color="green"
>
Submit Signature
</Button>
<Button
leftSection={<CancelIcon fontSize="small" />}
onClick={handleDecline}
color="red"
variant="light"
>
Decline
</Button>
</Group>
</Stack>
</Card>
)}
{participant.hasCompleted && (
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
You have {participant.status === 'SIGNED' ? 'signed' : 'declined'} this document.
</Alert>
)}
{participant.isExpired && (
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
Your access to this document has expired.
</Alert>
)}
</Stack>
);
};
export default ParticipantView;
@@ -0,0 +1,133 @@
import { useState, useCallback, useEffect } from 'react';
import workflowService, {
WorkflowSessionResponse,
ParticipantResponse,
SignatureSubmissionRequest,
} from '@app/services/workflowService';
export interface UseParticipantSessionResult {
session: WorkflowSessionResponse | null;
participant: ParticipantResponse | null;
loading: boolean;
error: string | null;
loadSession: (token: string) => Promise<void>;
submitSignature: (request: SignatureSubmissionRequest) => Promise<void>;
decline: (token: string, reason?: string) => Promise<void>;
downloadDocument: (token: string) => Promise<void>;
}
/**
* Hook for managing workflow session from participant perspective
*/
export const useParticipantSession = (token?: string): UseParticipantSessionResult => {
const [session, setSession] = useState<WorkflowSessionResponse | null>(null);
const [participant, setParticipant] = useState<ParticipantResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadSession = useCallback(async (token: string) => {
setLoading(true);
setError(null);
try {
const [sessionData, participantData] = await Promise.all([
workflowService.getSessionByToken(token),
workflowService.getParticipantDetails(token),
]);
setSession(sessionData);
setParticipant(participantData);
} catch (err: any) {
const errorMsg =
err.response?.data?.message || err.message || 'Failed to load session';
setError(errorMsg);
} finally {
setLoading(false);
}
}, []);
const submitSignature = useCallback(
async (request: SignatureSubmissionRequest) => {
setLoading(true);
setError(null);
try {
const updatedParticipant = await workflowService.submitSignature(request);
setParticipant(updatedParticipant);
// Reload session to get updated status
if (request.participantToken) {
await loadSession(request.participantToken);
}
} catch (err: any) {
const errorMsg =
err.response?.data?.message || err.message || 'Failed to submit signature';
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
setLoading(false);
}
},
[loadSession]
);
const decline = useCallback(
async (token: string, reason?: string) => {
setLoading(true);
setError(null);
try {
const updatedParticipant = await workflowService.declineParticipation(
token,
reason
);
setParticipant(updatedParticipant);
// Reload session
await loadSession(token);
} catch (err: any) {
const errorMsg =
err.response?.data?.message || err.message || 'Failed to decline';
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
setLoading(false);
}
},
[loadSession]
);
const downloadDocument = useCallback(async (token: string) => {
setLoading(true);
setError(null);
try {
const pdfBlob = await workflowService.getParticipantDocument(token);
const url = window.URL.createObjectURL(pdfBlob);
const a = document.createElement('a');
a.href = url;
a.download = session?.documentName || 'document.pdf';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err: any) {
const errorMsg =
err.response?.data?.message || err.message || 'Failed to download document';
setError(errorMsg);
} finally {
setLoading(false);
}
}, [session]);
// Auto-load session if token is provided
useEffect(() => {
if (token) {
loadSession(token);
}
}, [token, loadSession]);
return {
session,
participant,
loading,
error,
loadSession,
submitSignature,
decline,
downloadDocument,
};
};
+6 -2
View File
@@ -98,7 +98,9 @@ export default function Landing() {
// If login is disabled, show app directly (anonymous mode)
if (config?.enableLogin === false || backendProbe.loginDisabled) {
console.debug('[Landing] Login disabled - showing app in anonymous mode');
return <HomePage />;
return (
<HomePage />
);
}
// If backend is not up yet and user is not authenticated, show a branded status screen
@@ -143,7 +145,9 @@ export default function Landing() {
// If we have a session, show the main app
// Note: First login password change is now handled by the onboarding flow
if (session) {
return <HomePage />;
return (
<HomePage />
);
}
// No session - redirect to login page
+4 -3
View File
@@ -142,10 +142,11 @@ export default function Login() {
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
useEffect(() => {
if (!loading && session) {
console.debug('[Login] User already authenticated, redirecting to home');
navigate('/', { replace: true });
const returnPath = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname;
console.debug('[Login] User already authenticated, redirecting to home', { returnPath });
navigate(returnPath || '/', { replace: true });
}
}, [session, loading, navigate]);
}, [session, loading, navigate, location.state]);
// If backend reports login is disabled, redirect to home (anonymous mode)
useEffect(() => {
@@ -0,0 +1,256 @@
import { useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { useAuth } from '@app/auth/UseSession';
import { useFileActions } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { alert } from '@app/components/toast';
import type { StirlingFile } from '@app/types/fileContext';
import type { FileId } from '@app/types/file';
import { fileStorage } from '@app/services/fileStorage';
import {
getShareBundleEntryRootId,
isZipBundle,
loadShareBundleEntries,
parseContentDispositionFilename,
} from '@app/services/shareBundleUtils';
interface ShareLinkLoaderProps {
token: string;
}
interface ShareLinkMetadata {
shareToken?: string;
fileId?: number;
fileName?: string;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
expiresAt?: string;
}
export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions();
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
const handledTokenRef = useRef<string | null>(null);
const normalizedToken = useMemo(() => token.trim(), [token]);
const isAuthenticated = Boolean(user);
useEffect(() => {
if (!normalizedToken) {
return;
}
if (handledTokenRef.current === normalizedToken) {
return;
}
handledTokenRef.current = normalizedToken;
const abortController = new AbortController();
const { signal } = abortController;
const loadSharedFile = async () => {
try {
let shareMetadata: ShareLinkMetadata | null = null;
try {
const metadataResponse = await apiClient.get<ShareLinkMetadata>(
`/api/v1/storage/share-links/${normalizedToken}/metadata`,
{ suppressErrorToast: true, skipAuthRedirect: true, signal }
);
shareMetadata = metadataResponse.data;
} catch {
shareMetadata = null;
}
const response = await apiClient.get(`/api/v1/storage/share-links/${normalizedToken}`, {
responseType: 'blob',
suppressErrorToast: true,
skipAuthRedirect: true,
signal,
});
if (signal.aborted) return;
const contentType =
(response.headers && (response.headers['content-type'] || response.headers['Content-Type'])) ||
'';
const disposition =
(response.headers &&
(response.headers['content-disposition'] || response.headers['Content-Disposition'])) ||
'';
const filename = parseContentDispositionFilename(disposition) || 'shared-file';
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
if (isZipBundle(contentTypeValue, filename)) {
const bundle = await loadShareBundleEntries(blob);
if (bundle) {
const { manifest, rootOrder, sortedEntries, files } = bundle;
const stirlingFiles = await actions.addFilesWithOptions(files, {
selectFiles: false,
autoUnzip: false,
skipAutoUnzip: false,
allowDuplicates: true,
});
if (signal.aborted) return;
const idMap = new Map<string, FileId>();
for (let i = 0; i < stirlingFiles.length; i += 1) {
idMap.set(sortedEntries[i].logicalId, stirlingFiles[i].fileId as FileId);
}
const rootIdMap = new Map<string, FileId>();
for (const rootLogicalId of rootOrder) {
const mappedId = idMap.get(rootLogicalId);
if (mappedId) {
rootIdMap.set(rootLogicalId, mappedId);
}
}
for (let i = 0; i < sortedEntries.length; i += 1) {
const entry = sortedEntries[i];
const newId = idMap.get(entry.logicalId);
if (!newId) continue;
const parentId = entry.parentLogicalId
? idMap.get(entry.parentLogicalId)
: undefined;
const rootId =
rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) ||
idMap.get(manifest.rootLogicalId) ||
newId;
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
};
actions.updateStirlingFileStub(newId, {
versionNumber: entry.versionNumber,
originalFileId: rootId,
parentFileId: parentId,
toolHistory: entry.toolHistory,
isLeaf: entry.isLeaf,
...sharedUpdates,
});
await fileStorage.updateFileMetadata(newId, {
versionNumber: entry.versionNumber,
originalFileId: rootId,
parentFileId: parentId,
toolHistory: entry.toolHistory,
isLeaf: entry.isLeaf,
...sharedUpdates,
});
}
const selectedIds: FileId[] = [];
for (const rootId of rootOrder) {
const rootEntries = sortedEntries.filter(
(entry) => getShareBundleEntryRootId(manifest, entry) === rootId
);
const latestEntry = rootEntries[rootEntries.length - 1];
if (!latestEntry) {
continue;
}
const latestId = idMap.get(latestEntry.logicalId);
if (latestId) {
selectedIds.push(latestId);
}
}
if (selectedIds.length > 0) {
actions.setSelectedFiles(selectedIds);
}
navActions.setWorkbench('viewer');
navigate('/', { replace: true });
return;
}
}
const file = new File([blob], filename, { type: contentTypeValue || blob.type });
const stirlingFiles = await actions.addFilesWithOptions([file], {
selectFiles: true,
autoUnzip: false,
skipAutoUnzip: false,
});
if (signal.aborted) return;
if (stirlingFiles.length > 0) {
const ids = stirlingFiles.map((stirlingFile: StirlingFile) => stirlingFile.fileId);
actions.setSelectedFiles(ids);
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
};
for (const fileId of ids) {
actions.updateStirlingFileStub(fileId, sharedUpdates);
await fileStorage.updateFileMetadata(fileId, sharedUpdates);
}
}
navActions.setWorkbench('viewer');
navigate('/', { replace: true });
} catch (error: any) {
if (signal.aborted) return;
const status = error?.response?.status;
if (status === 401 || status === 403) {
if (!isAuthenticated && !authLoading) {
alert({
alertType: 'warning',
title: t('storageShare.requiresLogin', 'This shared file requires login.'),
expandable: false,
durationMs: 4000,
});
navigate('/login', {
replace: true,
state: { from: { pathname: `/share/${normalizedToken}` } },
});
return;
}
alert({
alertType: 'warning',
title: t(
'storageShare.accessDenied',
'You do not have access to this shared file. Ask the owner to share it with you.'
),
expandable: false,
durationMs: 4500,
});
navigate('/', { replace: true });
} else if (status === 404 || status === 410) {
alert({
alertType: 'error',
title: t('storageShare.expiredTitle', 'Link expired'),
expandable: false,
durationMs: 4000,
});
navigate('/', { replace: true });
} else {
alert({
alertType: 'error',
title: t('storageShare.loadFailed', 'Unable to open shared file.'),
expandable: false,
durationMs: 4000,
});
}
}
};
loadSharedFile();
return () => {
abortController.abort();
};
}, [normalizedToken, actions, navActions, navigate, t, isAuthenticated, authLoading]);
return null;
}
@@ -0,0 +1,261 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Badge, Button, Group, Loader, Paper, Stack, Text, Title } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
import LoginIcon from '@mui/icons-material/Login';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useFileActions } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { alert } from '@app/components/toast';
import {
downloadShareLink,
fetchShareLinkMetadata,
importShareLinkToWorkbench,
ShareLinkMetadata,
} from '@app/services/shareLinkImport';
type ShareLinkStatus = 'loading' | 'ready' | 'login' | 'forbidden' | 'notfound' | 'error';
export default function ShareLinkPage() {
const { token } = useParams<{ token: string }>();
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions();
const navigate = useNavigate();
const { t } = useTranslation();
const [status, setStatus] = useState<ShareLinkStatus>('loading');
const [metadata, setMetadata] = useState<ShareLinkMetadata | null>(null);
const [isWorking, setIsWorking] = useState(false);
const normalizedToken = useMemo(() => (token || '').trim(), [token]);
const shareRole = (metadata?.accessRole ?? 'viewer').toLowerCase();
const hasReadAccess =
shareRole === 'editor' || shareRole === 'commenter' || shareRole === 'viewer';
const canDownload = hasReadAccess;
const canOpen = hasReadAccess;
const loadMetadata = useCallback(async () => {
if (!normalizedToken) {
setStatus('notfound');
return;
}
setStatus('loading');
try {
const data = await fetchShareLinkMetadata(normalizedToken);
setMetadata(data);
setStatus('ready');
} catch (error: any) {
const statusCode = error?.response?.status as number | undefined;
if (statusCode === 401) {
setStatus('login');
} else if (statusCode === 403) {
setStatus('forbidden');
} else if (statusCode === 404 || statusCode === 410) {
setStatus('notfound');
} else {
setStatus('error');
}
}
}, [normalizedToken]);
useEffect(() => {
void loadMetadata();
}, [loadMetadata]);
const handleLogin = useCallback(() => {
navigate('/login', {
replace: true,
state: { from: { pathname: `/share/${normalizedToken}` } },
});
}, [navigate, normalizedToken]);
const handleDownload = useCallback(async () => {
if (!normalizedToken || !canDownload) return;
setIsWorking(true);
try {
const { blob, filename } = await downloadShareLink(normalizedToken);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename || 'shared-file';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error: any) {
const statusCode = error?.response?.status as number | undefined;
if (statusCode === 401) {
setStatus('login');
} else if (statusCode === 403) {
setStatus('forbidden');
} else if (statusCode === 404 || statusCode === 410) {
setStatus('notfound');
} else {
alert({
alertType: 'error',
title: t('storageShare.downloadFailed', 'Unable to download this file.'),
expandable: false,
durationMs: 3500,
});
}
} finally {
setIsWorking(false);
}
}, [canDownload, normalizedToken, t]);
const handleOpen = useCallback(async () => {
if (!normalizedToken || !canOpen) return;
setIsWorking(true);
try {
const selectedIds = await importShareLinkToWorkbench(
normalizedToken,
actions,
metadata
);
if (selectedIds.length > 0) {
actions.setSelectedFiles(selectedIds);
}
navActions.setWorkbench('viewer');
navigate('/', { replace: true });
} catch (error: any) {
const statusCode = error?.response?.status as number | undefined;
if (statusCode === 401) {
setStatus('login');
} else if (statusCode === 403) {
setStatus('forbidden');
} else if (statusCode === 404 || statusCode === 410) {
setStatus('notfound');
} else {
alert({
alertType: 'error',
title: t('storageShare.loadFailed', 'Unable to open shared file.'),
expandable: false,
durationMs: 3500,
});
}
} finally {
setIsWorking(false);
}
}, [actions, canOpen, metadata, navActions, navigate, normalizedToken, t]);
const title = metadata?.fileName || t('storageShare.titleDefault', 'Shared file');
const ownerLabel = metadata?.owner || t('storageShare.ownerUnknown', 'Unknown');
return (
<div style={{ minHeight: '100%', padding: '2.5rem 1.5rem' }}>
<Paper radius="lg" p="xl" withBorder shadow="sm" style={{ maxWidth: 720, margin: '0 auto' }}>
<Stack gap="md">
<Group justify="space-between" align="center">
<Title order={3}>{t('storageShare.shareHeading', 'Shared file')}</Title>
<Group gap="xs">
{metadata?.accessRole && (
<Badge variant="light" color="gray">
{shareRole === 'editor'
? t('storageShare.roleEditor', 'Editor')
: shareRole === 'commenter'
? t('storageShare.roleCommenter', 'Commenter')
: t('storageShare.roleViewer', 'Viewer')}
</Badge>
)}
</Group>
</Group>
{status === 'loading' && (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('storageShare.loading', 'Loading share link...')}
</Text>
</Group>
)}
{status === 'ready' && (
<>
<Text size="lg" fw={600}>
{title}
</Text>
<Text size="sm" c="dimmed">
{t('storageShare.ownerLabel', 'Owner')}: {ownerLabel}
</Text>
{metadata?.createdAt && (
<Text size="sm" c="dimmed">
{t('storageShare.createdAt', 'Created')} {new Date(metadata.createdAt).toLocaleString()}
</Text>
)}
<Group justify="flex-start" gap="sm" pt="sm">
<Button
leftSection={<OpenInNewIcon style={{ fontSize: 18 }} />}
onClick={handleOpen}
loading={isWorking}
disabled={!canOpen}
>
{t('storageShare.openInApp', 'Open in Stirling PDF')}
</Button>
<Button
variant="light"
leftSection={<DownloadIcon style={{ fontSize: 18 }} />}
onClick={handleDownload}
loading={isWorking}
disabled={!canDownload}
>
{t('storageShare.download', 'Download')}
</Button>
</Group>
{!canDownload && (
<Alert mt="md" color="yellow" title={t('storageShare.accessLimitedTitle', 'Limited access')}>
{shareRole === 'commenter'
? t(
'storageShare.accessLimitedCommenter',
'Comment access is coming soon. Ask the owner for editor access if you need to download.'
)
: t(
'storageShare.accessLimitedViewer',
'This link is view-only. Ask the owner for editor access if you need to download.'
)}
</Alert>
)}
</>
)}
{status === 'login' && (
<Alert color="blue" title={t('storageShare.loginRequired', 'Login required')}>
<Text size="sm">
{t('storageShare.loginPrompt', 'Sign in to access this shared file.')}
</Text>
<Group mt="md">
<Button
leftSection={<LoginIcon style={{ fontSize: 18 }} />}
onClick={handleLogin}
>
{t('storageShare.goToLogin', 'Go to login')}
</Button>
</Group>
</Alert>
)}
{status === 'forbidden' && (
<Alert color="red" title={t('storageShare.accessDeniedTitle', 'No access')}>
{t(
'storageShare.accessDeniedBody',
'You do not have access to this file. Ask the owner to share it with you.'
)}
</Alert>
)}
{status === 'notfound' && (
<Alert color="red" title={t('storageShare.expiredTitle', 'Link expired')}>
{t('storageShare.expiredBody', 'This share link is invalid or has expired.')}
</Alert>
)}
{status === 'error' && (
<Alert color="red" title={t('storageShare.loadFailed', 'Unable to open shared file.')}>
{t('storageShare.tryAgain', 'Please try again later.')}
</Alert>
)}
</Stack>
</Paper>
</div>
);
}
@@ -0,0 +1,162 @@
import apiClient from '@app/services/apiClient';
import { fileStorage } from '@app/services/fileStorage';
import type { FileId } from '@app/types/file';
import type { StirlingFile } from '@app/types/fileContext';
import type { FileContextActions } from '@app/types/fileContext';
import {
getShareBundleEntryRootId,
isZipBundle,
loadShareBundleEntries,
parseContentDispositionFilename,
} from '@app/services/shareBundleUtils';
export interface ShareLinkMetadata {
shareToken?: string;
fileId?: number;
fileName?: string;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
createdAt?: string;
expiresAt?: string;
}
export async function fetchShareLinkMetadata(token: string): Promise<ShareLinkMetadata> {
const response = await apiClient.get<ShareLinkMetadata>(
`/api/v1/storage/share-links/${token}/metadata`,
{ suppressErrorToast: true, skipAuthRedirect: true } as any
);
return response.data || {};
}
export async function downloadShareLink(token: string): Promise<{
blob: Blob;
filename: string;
contentType: string;
}> {
const response = await apiClient.get(`/api/v1/storage/share-links/${token}`, {
responseType: 'blob',
suppressErrorToast: true,
skipAuthRedirect: true,
} as any);
const contentType =
(response.headers && (response.headers['content-type'] || response.headers['Content-Type'])) ||
'';
const disposition =
(response.headers &&
(response.headers['content-disposition'] || response.headers['Content-Disposition'])) ||
'';
const filename = parseContentDispositionFilename(disposition) || 'shared-file';
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
return { blob, filename, contentType: contentTypeValue };
}
export async function importShareLinkToWorkbench(
token: string,
actions: FileContextActions,
shareMetadata?: ShareLinkMetadata | null
): Promise<FileId[]> {
const { blob, filename, contentType } = await downloadShareLink(token);
const contentTypeValue = contentType || blob.type;
if (isZipBundle(contentTypeValue, filename)) {
const bundle = await loadShareBundleEntries(blob);
if (bundle) {
const { manifest, rootOrder, sortedEntries, files } = bundle;
const stirlingFiles = await actions.addFilesWithOptions(files, {
selectFiles: false,
autoUnzip: false,
skipAutoUnzip: false,
allowDuplicates: true,
});
const idMap = new Map<string, FileId>();
for (let i = 0; i < stirlingFiles.length; i += 1) {
idMap.set(sortedEntries[i].logicalId, stirlingFiles[i].fileId as FileId);
}
const rootIdMap = new Map<string, FileId>();
for (const rootLogicalId of rootOrder) {
const mappedId = idMap.get(rootLogicalId);
if (mappedId) {
rootIdMap.set(rootLogicalId, mappedId);
}
}
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || token,
};
for (const entry of sortedEntries) {
const newId = idMap.get(entry.logicalId);
if (!newId) continue;
const parentId = entry.parentLogicalId
? idMap.get(entry.parentLogicalId)
: undefined;
const rootId =
rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) ||
idMap.get(manifest.rootLogicalId) ||
newId;
const updates = {
versionNumber: entry.versionNumber,
originalFileId: rootId,
parentFileId: parentId,
toolHistory: entry.toolHistory,
isLeaf: entry.isLeaf,
...sharedUpdates,
};
actions.updateStirlingFileStub(newId, updates);
await fileStorage.updateFileMetadata(newId, updates);
}
const selectedIds: FileId[] = [];
for (const rootId of rootOrder) {
const rootEntries = sortedEntries.filter(
(entry) => getShareBundleEntryRootId(manifest, entry) === rootId
);
const latestEntry = rootEntries[rootEntries.length - 1];
if (!latestEntry) {
continue;
}
const latestId = idMap.get(latestEntry.logicalId);
if (latestId) {
selectedIds.push(latestId);
}
}
return selectedIds;
}
}
const file = new File([blob], filename, { type: contentTypeValue || blob.type });
const stirlingFiles = await actions.addFilesWithOptions([file], {
selectFiles: true,
autoUnzip: false,
skipAutoUnzip: false,
});
const ids = stirlingFiles.map((stirlingFile: StirlingFile) => stirlingFile.fileId as FileId);
if (ids.length > 0) {
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || token,
};
for (const fileId of ids) {
actions.updateStirlingFileStub(fileId, sharedUpdates);
await fileStorage.updateFileMetadata(fileId, sharedUpdates);
}
}
return ids;
}
@@ -0,0 +1,122 @@
import api from '@app/services/apiClient';
export interface ParticipantResponse {
id: number;
userId?: number;
email: string;
name: string;
status: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED';
shareToken: string;
accessRole: 'EDITOR' | 'COMMENTER' | 'VIEWER';
expiresAt?: string;
lastUpdated: string;
hasCompleted: boolean;
isExpired: boolean;
}
export interface WorkflowSessionResponse {
sessionId: string;
ownerId: number;
ownerUsername: string;
workflowType: 'SIGNING' | 'REVIEW' | 'APPROVAL';
documentName: string;
ownerEmail?: string;
message?: string;
dueDate?: string;
status: 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
finalized: boolean;
createdAt: string;
updatedAt: string;
participants: ParticipantResponse[];
hasProcessedFile: boolean;
originalFileId?: number;
processedFileId?: number;
}
export interface SignatureSubmissionRequest {
participantToken: string;
certType?: string;
password?: string;
p12File?: File;
jksFile?: File;
showSignature?: boolean;
pageNumber?: number;
location?: string;
reason?: string;
showLogo?: boolean;
wetSignatureData?: string;
}
/**
* Service for managing workflow sessions
*/
class WorkflowService {
/**
* Get session details by participant token (no authentication required)
*/
async getSessionByToken(token: string): Promise<WorkflowSessionResponse> {
const response = await api.get('/api/v1/workflow/participant/session', {
params: { token },
});
return response.data;
}
/**
* Get participant details by token
*/
async getParticipantDetails(token: string): Promise<ParticipantResponse> {
const response = await api.get('/api/v1/workflow/participant/details', {
params: { token },
});
return response.data;
}
/**
* Submit signature as a participant
*/
async submitSignature(request: SignatureSubmissionRequest): Promise<ParticipantResponse> {
const formData = new FormData();
formData.append('participantToken', request.participantToken);
if (request.certType) formData.append('certType', request.certType);
if (request.password) formData.append('password', request.password);
if (request.p12File) formData.append('p12File', request.p12File);
if (request.jksFile) formData.append('jksFile', request.jksFile);
if (request.showSignature !== undefined) formData.append('showSignature', request.showSignature.toString());
if (request.pageNumber) formData.append('pageNumber', request.pageNumber.toString());
if (request.location) formData.append('location', request.location);
if (request.reason) formData.append('reason', request.reason);
if (request.showLogo !== undefined) formData.append('showLogo', request.showLogo.toString());
if (request.wetSignatureData) formData.append('wetSignatureData', request.wetSignatureData);
const response = await api.post('/api/v1/workflow/participant/submit-signature', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
/**
* Decline participation
*/
async declineParticipation(token: string, reason?: string): Promise<ParticipantResponse> {
const response = await api.post('/api/v1/workflow/participant/decline', null, {
params: { token, reason },
});
return response.data;
}
/**
* Download document as participant
*/
async getParticipantDocument(token: string): Promise<Blob> {
const response = await api.get('/api/v1/workflow/participant/document', {
params: { token },
responseType: 'blob',
});
return response.data;
}
}
export default new WorkflowService();