mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
fileshare (#5414)
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:
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
@@ -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'),
|
||||
|
||||
+294
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user