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
@@ -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;