mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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
@@ -15,7 +15,9 @@ interface DrawingCanvasProps {
|
||||
onPenSizeInputChange: (input: string) => void;
|
||||
onSignatureDataChange: (data: string | null) => void;
|
||||
onDrawingComplete?: () => void;
|
||||
onModalClose?: () => void;
|
||||
disabled?: boolean;
|
||||
autoOpen?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
modalWidth?: number;
|
||||
@@ -33,7 +35,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
onPenSizeInputChange,
|
||||
onSignatureDataChange,
|
||||
onDrawingComplete,
|
||||
onModalClose,
|
||||
disabled = false,
|
||||
autoOpen = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
initialSignatureData,
|
||||
@@ -83,6 +87,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoOpen) openModal();
|
||||
}, [autoOpen]);
|
||||
|
||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas.toDataURL('image/png');
|
||||
@@ -160,6 +168,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(false);
|
||||
onModalClose?.();
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
@@ -26,6 +28,9 @@ import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverAction
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
||||
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +65,7 @@ const FileEditorThumbnail = ({
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadOutlinedIcon = icons.download;
|
||||
@@ -80,6 +86,10 @@ const FileEditorThumbnail = ({
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
|
||||
const sharedEditNoticeShownRef = useRef(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
@@ -115,6 +125,17 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const isCBZ = extLower === 'cbz';
|
||||
const isCBR = extLower === 'cbr';
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||
const isSharedFile = file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
|
||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||
const isUploaded = Boolean(file.remoteStorageId);
|
||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
|
||||
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
||||
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
@@ -248,6 +269,30 @@ const FileEditorThumbnail = ({
|
||||
onDownloadFile(file.id);
|
||||
},
|
||||
},
|
||||
...(canUpload || canShare
|
||||
? [
|
||||
...(canUpload ? [{
|
||||
id: 'upload',
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t('fileManager.updateOnServer', 'Update on Server')
|
||||
: t('fileManager.uploadToServer', 'Upload to Server'),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
}] : []),
|
||||
...(canShare ? [{
|
||||
id: 'share',
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t('fileManager.share', 'Share'),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
}] : []),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'unzip',
|
||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||
@@ -271,7 +316,22 @@ const FileEditorThumbnail = ({
|
||||
},
|
||||
color: 'red',
|
||||
}
|
||||
], [t, file.id, file.name, isZipFile, isCBR, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
|
||||
], [
|
||||
t,
|
||||
file.id,
|
||||
file.name,
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
terminology,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
handleCloseWithConfirmation,
|
||||
canUpload,
|
||||
canShare,
|
||||
isUploaded
|
||||
]);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
@@ -280,6 +340,10 @@ const FileEditorThumbnail = ({
|
||||
if (hasError) {
|
||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||
}
|
||||
if (isSharedFile && !sharedEditNoticeShownRef.current) {
|
||||
sharedEditNoticeShownRef.current = true;
|
||||
setShowSharedEditNotice(true);
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
@@ -537,6 +601,42 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={showSharedEditNotice}
|
||||
onClose={() => setShowSharedEditNotice(false)}
|
||||
title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')}
|
||||
centered
|
||||
size="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'fileManager.sharedEditNoticeBody',
|
||||
'You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.'
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button onClick={() => setShowSharedEditNotice(false)}>
|
||||
{t('fileManager.sharedEditNoticeConfirm', 'Got it')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{canUpload && (
|
||||
<UploadToServerModal
|
||||
opened={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ShareFileModal
|
||||
opened={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,6 +34,13 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
const hasMultipleFiles = numberOfFiles > 1;
|
||||
const showOwner = Boolean(
|
||||
currentFile &&
|
||||
(currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink)
|
||||
);
|
||||
const ownerLabel = currentFile
|
||||
? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
@@ -88,6 +95,11 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')}
|
||||
</Text>
|
||||
)}
|
||||
{currentFile && showOwner && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('fileManager.owner', 'Owner')}: {ownerLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation arrows for multiple files */}
|
||||
|
||||
@@ -1,25 +1,68 @@
|
||||
import React from "react";
|
||||
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import React, { useEffect } from "react";
|
||||
import { Group, Text, ActionIcon, Tooltip, SegmentedControl } from "@mantine/core";
|
||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import BulkShareModal from "@app/components/shared/BulkShareModal";
|
||||
|
||||
const FileActions: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadIcon = icons.download;
|
||||
const { config } = useAppConfig();
|
||||
const [showBulkUploadModal, setShowBulkUploadModal] = React.useState(false);
|
||||
const [showBulkShareModal, setShowBulkShareModal] = React.useState(false);
|
||||
const {
|
||||
recentFiles,
|
||||
selectedFileIds,
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
onSelectAll,
|
||||
onDeleteSelected,
|
||||
onDownloadSelected
|
||||
} = useFileManagerContext();
|
||||
onDownloadSelected,
|
||||
refreshRecentFiles,
|
||||
storageFilter,
|
||||
onStorageFilterChange
|
||||
} =
|
||||
useFileManagerContext();
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const showStorageFilter = uploadEnabled;
|
||||
const storageFilterOptions = sharingEnabled
|
||||
? [
|
||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
|
||||
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }
|
||||
]
|
||||
: [
|
||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||
{ value: "local", label: t("fileManager.filterLocal", "Local") }
|
||||
];
|
||||
useEffect(() => {
|
||||
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
|
||||
onStorageFilterChange("all");
|
||||
}
|
||||
}, [sharingEnabled, storageFilter, onStorageFilterChange]);
|
||||
const hasSelection = selectedFileIds.length > 0;
|
||||
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
|
||||
const hasDownloadAccess = selectedFiles.every((file) => {
|
||||
const role = (file.remoteOwnedByCurrentUser !== false
|
||||
? 'editor'
|
||||
: (file.remoteAccessRole ?? 'viewer')).toLowerCase();
|
||||
return role === 'editor' || role === 'commenter' || role === 'viewer';
|
||||
});
|
||||
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onSelectAll();
|
||||
@@ -44,7 +87,6 @@ const FileActions: React.FC = () => {
|
||||
}
|
||||
|
||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||
const hasSelection = selectedFileIds.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,8 +100,8 @@ const FileActions: React.FC = () => {
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Left: Select All */}
|
||||
<div>
|
||||
{/* Left: Select All + Filter */}
|
||||
<Group gap="xs">
|
||||
<Tooltip
|
||||
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
||||
>
|
||||
@@ -74,7 +116,17 @@ const FileActions: React.FC = () => {
|
||||
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{showStorageFilter && (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={storageFilter}
|
||||
onChange={(value) =>
|
||||
onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")
|
||||
}
|
||||
data={storageFilterOptions}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Center: Selected count */}
|
||||
<div
|
||||
@@ -93,6 +145,34 @@ const FileActions: React.FC = () => {
|
||||
|
||||
{/* Right: Delete and Download */}
|
||||
<Group gap="xs">
|
||||
{uploadEnabled && (
|
||||
<Tooltip label={t("fileManager.uploadSelected", "Upload Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={() => setShowBulkUploadModal(true)}
|
||||
disabled={!canBulkUpload}
|
||||
radius="sm"
|
||||
>
|
||||
<CloudUploadIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shareLinksEnabled && (
|
||||
<Tooltip label={t("fileManager.shareSelected", "Share Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={() => setShowBulkShareModal(true)}
|
||||
disabled={!canBulkShare}
|
||||
radius="sm"
|
||||
>
|
||||
<LinkIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
@@ -112,13 +192,30 @@ const FileActions: React.FC = () => {
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleDownloadSelected}
|
||||
disabled={!hasSelection}
|
||||
disabled={!hasSelection || !hasDownloadAccess}
|
||||
radius="sm"
|
||||
>
|
||||
<DownloadIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{uploadEnabled && (
|
||||
<BulkUploadToServerModal
|
||||
opened={showBulkUploadModal}
|
||||
onClose={() => setShowBulkUploadModal(false)}
|
||||
files={selectedFiles}
|
||||
onUploaded={refreshRecentFiles}
|
||||
/>
|
||||
)}
|
||||
{shareLinksEnabled && (
|
||||
<BulkShareModal
|
||||
opened={showBulkShareModal}
|
||||
onClose={() => setShowBulkShareModal(false)}
|
||||
files={selectedFiles}
|
||||
onShared={refreshRecentFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import ToolChain from '@app/components/shared/ToolChain';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
interface FileInfoCardProps {
|
||||
currentFile: StirlingFileStub | null;
|
||||
@@ -16,6 +19,40 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
modalHeight
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { onMakeCopy } = useFileManagerContext();
|
||||
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||
const isSharedWithYou = useMemo(() => {
|
||||
if (!currentFile) return false;
|
||||
return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink;
|
||||
}, [currentFile]);
|
||||
const isOwnedRemote = useMemo(() => {
|
||||
if (!currentFile) return false;
|
||||
return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false;
|
||||
}, [currentFile]);
|
||||
const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
|
||||
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
|
||||
const isUploaded = Boolean(currentFile?.remoteStorageId);
|
||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
|
||||
const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
|
||||
const isSharedByYou = useMemo(() => {
|
||||
if (!currentFile) return false;
|
||||
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
|
||||
}, [currentFile, isOwnedRemote]);
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const ownerLabel = useMemo(() => {
|
||||
if (!currentFile) return '';
|
||||
if (currentFile.remoteOwnerUsername) {
|
||||
return currentFile.remoteOwnerUsername;
|
||||
}
|
||||
return t('fileManager.ownerUnknown', 'Unknown');
|
||||
}, [currentFile, t]);
|
||||
const lastSyncedLabel = useMemo(() => {
|
||||
if (!currentFile?.remoteStorageUpdatedAt) return '';
|
||||
return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString();
|
||||
}, [currentFile?.remoteStorageUpdatedAt]);
|
||||
|
||||
return (
|
||||
<Card withBorder p={0} mah={`calc(${modalHeight} * 0.45)`} style={{ overflow: 'hidden', flexShrink: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -57,7 +94,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last Modified')}</Text>
|
||||
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last modified')}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
|
||||
</Text>
|
||||
@@ -72,6 +109,20 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
</Badge>}
|
||||
|
||||
</Group>
|
||||
{sharingEnabled && isSharedWithYou && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.owner', 'Owner')}</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>{ownerLabel}</Text>
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
{t('fileManager.sharedWithYou', 'Shared with you')}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tool Chain Display */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
@@ -87,8 +138,83 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentFile && isSharedWithYou && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => onMakeCopy(currentFile)}
|
||||
fullWidth
|
||||
>
|
||||
{t('fileManager.makeCopy', 'Make a copy')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentFile && isOwnedRemote && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.cloudFile', 'Cloud file')}</Text>
|
||||
{uploadEnabled && isOutOfSync ? (
|
||||
<Badge size="xs" variant="light" color="yellow">
|
||||
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
|
||||
</Badge>
|
||||
) : uploadEnabled ? (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
{t('fileManager.synced', 'Synced')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{lastSyncedLabel && (
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.lastSynced', 'Last synced')}</Text>
|
||||
<Text size="sm" fw={500}>{lastSyncedLabel}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{isSharedByYou && sharingEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.sharing', 'Sharing')}</Text>
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{t('fileManager.sharedByYou', 'Shared by you')}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => setShowShareManageModal(true)}
|
||||
fullWidth
|
||||
>
|
||||
{t('storageShare.manage', 'Manage sharing')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentFile && isLocalOnly && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.storageState', 'Storage')}</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t('fileManager.localOnly', 'Local only')}
|
||||
</Badge>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
{currentFile && isOwnedRemote && isSharedByYou && sharingEnabled && (
|
||||
<ShareManagementModal
|
||||
opened={showShareManageModal}
|
||||
onClose={() => setShowShareManageModal(false)}
|
||||
file={currentFile}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
@@ -7,6 +7,9 @@ import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CloudDoneIcon from '@mui/icons-material/CloudDone';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '@app/utils/fileUtils';
|
||||
import { FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||
@@ -16,6 +19,13 @@ import ToolChain from '@app/components/shared/ToolChain';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { useFileManagement } from '@app/contexts/FileContext';
|
||||
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
||||
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { alert } from '@app/components/toast';
|
||||
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -45,8 +55,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||
const { config } = useAppConfig();
|
||||
const {expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
@@ -65,6 +79,73 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||
const isExpanded = expandedFileIds.has(leafFileId);
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||
const isSharedWithYou =
|
||||
sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||
const isUploaded = Boolean(file.remoteStorageId);
|
||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
|
||||
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
|
||||
const accessRole = (isOwnedOrLocal ? 'editor' : (file.remoteAccessRole ?? 'viewer')).toLowerCase();
|
||||
const hasReadAccess = isOwnedOrLocal || accessRole === 'editor' || accessRole === 'commenter' || accessRole === 'viewer';
|
||||
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
|
||||
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
|
||||
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
||||
const canCopyShareLink =
|
||||
shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
|
||||
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
|
||||
|
||||
const shareBaseUrl = useMemo(() => {
|
||||
const frontendUrl = (config?.frontendUrl || '').trim();
|
||||
if (frontendUrl) {
|
||||
const normalized = frontendUrl.endsWith('/')
|
||||
? frontendUrl.slice(0, -1)
|
||||
: frontendUrl;
|
||||
return `${normalized}/share/`;
|
||||
}
|
||||
return absoluteWithBasePath('/share/');
|
||||
}, [config?.frontendUrl]);
|
||||
|
||||
const handleCopyShareLink = useCallback(async () => {
|
||||
if (!file.remoteStorageId) return;
|
||||
try {
|
||||
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
|
||||
`/api/v1/storage/files/${file.remoteStorageId}`,
|
||||
{ suppressErrorToast: true } as any
|
||||
);
|
||||
const links = response.data?.shareLinks ?? [];
|
||||
const token = links[links.length - 1]?.token;
|
||||
if (!token) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.noLinks', 'No active share links yet.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.copied', 'Link copied to clipboard'),
|
||||
expandable: false,
|
||||
durationMs: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.copyFailed', 'Copy failed'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
}, [file.remoteStorageId, shareBaseUrl, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -133,6 +214,45 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
{sharingEnabled && isSharedWithYou ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
{t('fileManager.sharedWithYou', 'Shared with you')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{accessRole === 'commenter'
|
||||
? t('storageShare.roleCommenter', 'Commenter')
|
||||
: t('storageShare.roleViewer', 'Viewer')}
|
||||
</Badge>
|
||||
) : isLocalOnly ? (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t('fileManager.localOnly', 'Local only')}
|
||||
</Badge>
|
||||
) : uploadEnabled && isOutOfSync ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
|
||||
</Badge>
|
||||
) : uploadEnabled && isUploaded ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
{t('fileManager.synced', 'Synced')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{t('fileManager.sharedByYou', 'Shared by you')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
</Group>
|
||||
<Group gap="xs" align="center">
|
||||
@@ -194,18 +314,68 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDownload && (
|
||||
{canDownloadFile && (
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload();
|
||||
onDownload?.();
|
||||
}}
|
||||
>
|
||||
{t('fileManager.download', 'Download')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
}}
|
||||
>
|
||||
{isUploaded
|
||||
? t('fileManager.updateOnServer', 'Update on Server')
|
||||
: t('fileManager.uploadToServer', 'Upload to Server')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
<Menu.Item
|
||||
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.share', 'Share')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{canCopyShareLink && (
|
||||
<Menu.Item
|
||||
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleCopyShareLink();
|
||||
}}
|
||||
>
|
||||
{t('storageShare.copyLink', 'Copy share link')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{canManageShare && (
|
||||
<Menu.Item
|
||||
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowShareManageModal(true);
|
||||
}}
|
||||
>
|
||||
{t('storageShare.manage', 'Manage sharing')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{/* Show/Hide History option for latest version files */}
|
||||
{isLatestVersion && hasVersionHistory && (
|
||||
<>
|
||||
@@ -275,6 +445,29 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</Group>
|
||||
</Box>
|
||||
{ <Divider color="var(--mantine-color-gray-3)" />}
|
||||
{canUpload && (
|
||||
<UploadToServerModal
|
||||
opened={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
file={file}
|
||||
onUploaded={refreshRecentFiles}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ShareFileModal
|
||||
opened={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
file={file}
|
||||
onUploaded={refreshRecentFiles}
|
||||
/>
|
||||
)}
|
||||
{canManageShare && (
|
||||
<ShareManagementModal
|
||||
opened={showShareManageModal}
|
||||
onClose={() => setShowShareManageModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,20 +93,16 @@ export default function Workbench() {
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
// Check for custom workbench views first
|
||||
// Check if we're showing a custom workbench first
|
||||
// Custom workbenches may not require files in FileContext (e.g., sign request workbench)
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
if (customView) {
|
||||
// PDF text editor handles its own empty state (shows dropzone when no document)
|
||||
const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
|
||||
if (handlesOwnEmptyState || activeFiles.length > 0) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
}
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
}
|
||||
}
|
||||
|
||||
// For base workbenches (or custom views that don't handle empty state), show landing page when no files
|
||||
if (activeFiles.length === 0) {
|
||||
return (
|
||||
<LandingPage
|
||||
@@ -196,7 +192,7 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 && (
|
||||
{activeFiles.length > 0 && !customWorkbenchViews.find(v => v.workbenchId === currentView)?.hideTopControls && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { uploadHistoryChains } from '@app/services/serverStorageUpload';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import type { FileId } from '@app/types/file';
|
||||
|
||||
interface BulkShareModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
files: StirlingFileStub[];
|
||||
onShared?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
files,
|
||||
onShared,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { actions } = useFileActions();
|
||||
const shareLinksEnabled = config?.storageShareLinksEnabled === true;
|
||||
const [isWorking, setIsWorking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||
const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor');
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setIsWorking(false);
|
||||
setErrorMessage(null);
|
||||
setShareToken(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setShareRole('editor');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const shareUrl = useMemo(() => {
|
||||
if (!shareToken) return '';
|
||||
const frontendUrl = (config?.frontendUrl || '').trim();
|
||||
if (frontendUrl) {
|
||||
const normalized = frontendUrl.endsWith('/')
|
||||
? frontendUrl.slice(0, -1)
|
||||
: frontendUrl;
|
||||
return `${normalized}/share/${shareToken}`;
|
||||
}
|
||||
return absoluteWithBasePath(`/share/${shareToken}`);
|
||||
}, [config?.frontendUrl, shareToken]);
|
||||
|
||||
const createShareLink = useCallback(async (storedFileId: number) => {
|
||||
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
|
||||
accessRole: shareRole,
|
||||
});
|
||||
return response.data as { token?: string };
|
||||
}, [shareRole]);
|
||||
|
||||
const handleGenerateLink = useCallback(async () => {
|
||||
if (!shareLinksEnabled) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.linksDisabled', 'Share links are disabled.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsWorking(true);
|
||||
setErrorMessage(null);
|
||||
setShareToken(null);
|
||||
|
||||
try {
|
||||
const rootIds = Array.from(
|
||||
new Set(files.map((file) => (file.originalFileId || file.id) as FileId))
|
||||
);
|
||||
const remoteIds = Array.from(
|
||||
new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])
|
||||
);
|
||||
const existingRemoteId =
|
||||
rootIds.length === 1 && remoteIds.length === 1 ? remoteIds[0] : undefined;
|
||||
|
||||
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChains(
|
||||
rootIds,
|
||||
existingRemoteId
|
||||
);
|
||||
|
||||
const shareResponse = await createShareLink(storedId);
|
||||
setShareToken(shareResponse.token ?? null);
|
||||
|
||||
for (const stub of chain) {
|
||||
actions.updateStirlingFileStub(stub.id, {
|
||||
remoteStorageId: storedId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: true,
|
||||
});
|
||||
await fileStorage.updateFileMetadata(stub.id, {
|
||||
remoteStorageId: storedId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: true,
|
||||
});
|
||||
}
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.generated', 'Share link generated'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
if (onShared) {
|
||||
await onShared();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate share link:', error);
|
||||
setErrorMessage(
|
||||
t('storageShare.failure', 'Unable to generate a share link. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
}, [actions, createShareLink, files, onShared, shareLinksEnabled, t]);
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.copied', 'Link copied to clipboard'),
|
||||
expandable: false,
|
||||
durationMs: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.copyFailed', 'Copy failed'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
}, [shareUrl, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
title={t('storageShare.bulkTitle', 'Share selected files')}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
size="lg"
|
||||
overlayProps={{ blur: 6 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'storageShare.bulkDescription',
|
||||
'Create one link to share all selected files with signed-in users.'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageShare.fileCount', '{{count}} files selected', {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert color="red" title={t('storageShare.errorTitle', 'Share failed')}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{!shareLinksEnabled && (
|
||||
<Alert color="yellow" title={t('storageShare.linksDisabled', 'Share links are disabled.')}>
|
||||
{t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shareUrl && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
label={t('storageShare.linkLabel', 'Share link')}
|
||||
rightSection={
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{t('storageShare.copy', 'Copy')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('storageShare.linkAccessTitle', 'Share link access')}
|
||||
</Text>
|
||||
<Select
|
||||
label={t('storageShare.roleLabel', 'Role')}
|
||||
value={shareRole}
|
||||
onChange={(value) => setShareRole((value as typeof shareRole) || 'editor')}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
|
||||
data={[
|
||||
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
|
||||
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
|
||||
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
|
||||
]}
|
||||
/>
|
||||
{shareRole === 'commenter' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={isWorking}>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<LinkIcon style={{ fontSize: 18 }} />}
|
||||
onClick={handleGenerateLink}
|
||||
loading={isWorking}
|
||||
disabled={!shareLinksEnabled}
|
||||
>
|
||||
{t('storageShare.generate', 'Generate Link')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkShareModal;
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { alert } from '@app/components/toast';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { uploadHistoryChains } from '@app/services/serverStorageUpload';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import type { FileId } from '@app/types/file';
|
||||
|
||||
interface BulkUploadToServerModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
files: StirlingFileStub[];
|
||||
onUploaded?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
files,
|
||||
onUploaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions } = useFileActions();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const fileNames = useMemo(() => files.map((file) => file.name), [files]);
|
||||
const displayNames = useMemo(() => fileNames.slice(0, 3), [fileNames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setIsUploading(false);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
setIsUploading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const rootIds = Array.from(
|
||||
new Set(files.map((file) => (file.originalFileId || file.id) as FileId))
|
||||
);
|
||||
const remoteIds = Array.from(
|
||||
new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])
|
||||
);
|
||||
const existingRemoteId = remoteIds.length === 1 ? remoteIds[0] : undefined;
|
||||
|
||||
const { remoteId, updatedAt, chain } = await uploadHistoryChains(
|
||||
rootIds,
|
||||
existingRemoteId
|
||||
);
|
||||
|
||||
for (const stub of chain) {
|
||||
actions.updateStirlingFileStub(stub.id, {
|
||||
remoteStorageId: remoteId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
});
|
||||
await fileStorage.updateFileMetadata(stub.id, {
|
||||
remoteStorageId: remoteId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
});
|
||||
}
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageUpload.success', 'Uploaded to server'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
if (onUploaded) {
|
||||
await onUploaded();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload files to server:', error);
|
||||
setErrorMessage(
|
||||
t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.')
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [actions, files, onClose, onUploaded, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
title={t('storageUpload.bulkTitle', 'Upload selected files')}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'storageUpload.bulkDescription',
|
||||
'This uploads the selected files to your server storage.'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageUpload.fileCount', '{{count}} files selected', {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
{displayNames.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{displayNames.join(', ')}
|
||||
{fileNames.length > displayNames.length
|
||||
? t('storageUpload.more', ' +{{count}} more', {
|
||||
count: fileNames.length - displayNames.length,
|
||||
})
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert color="red" title={t('storageUpload.errorTitle', 'Upload failed')}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={isUploading}>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
|
||||
onClick={handleUpload}
|
||||
loading={isUploading}
|
||||
>
|
||||
{t('storageUpload.uploadButton', 'Upload to Server')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkUploadToServerModal;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button, Stack } from '@mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
export interface ButtonToggleOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ButtonToggleProps {
|
||||
options: ButtonToggleOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const ButtonToggle: React.FC<ButtonToggleProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
orientation = 'vertical',
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
}) => {
|
||||
const isVertical = orientation === 'vertical';
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
justifyContent: 'flex-start',
|
||||
height: isVertical ? 'auto' : undefined,
|
||||
minHeight: isVertical ? '50px' : undefined,
|
||||
padding: isVertical ? '12px 16px' : undefined,
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const renderButton = (option: ButtonToggleOption) => {
|
||||
const isSelected = value === option.value;
|
||||
const isDisabled = disabled || option.disabled;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={isSelected ? 'filled' : 'outline'}
|
||||
onClick={() => !isDisabled && onChange(option.value)}
|
||||
disabled={isDisabled}
|
||||
size={size}
|
||||
fullWidth={fullWidth}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ fontWeight: 600 }}>{option.label}</div>
|
||||
{option.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.85em',
|
||||
opacity: 0.8,
|
||||
marginTop: '4px',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (isVertical) {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{options.map(renderButton)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{options.map(renderButton)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -99,6 +99,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
{onFileRemove && (
|
||||
<Tooltip label="Close file" withArrow>
|
||||
<ActionIcon
|
||||
component="div"
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect, useMemo } from "react";
|
||||
import React, { useState, useRef, forwardRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Stack, Divider, Menu, Indicator } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import SignPopout, { SIGN_REQUEST_WORKBENCH_TYPE, SESSION_DETAIL_WORKBENCH_TYPE } from '@app/components/shared/signing/SignPopout';
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useFileSelection, useFileState } from '@app/contexts/file/fileHooks';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
||||
@@ -20,6 +23,16 @@ import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import { requestStartTour } from '@app/constants/events';
|
||||
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
|
||||
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
|
||||
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { uploadHistoryChain } from '@app/services/serverStorageUpload';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import type { FileId } from '@app/types/file';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import type { SignRequestSummary } from '@app/types/signingSession';
|
||||
|
||||
import {
|
||||
isNavButtonActive,
|
||||
@@ -36,15 +49,83 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const location = useLocation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool, toolAvailability } = useToolWorkflow();
|
||||
const { hasUnsavedChanges } = useNavigationState();
|
||||
const {
|
||||
handleReaderToggle,
|
||||
handleToolSelect,
|
||||
selectedToolKey,
|
||||
leftPanelView,
|
||||
toolRegistry,
|
||||
readerMode,
|
||||
resetTool,
|
||||
toolAvailability
|
||||
} = useToolWorkflow();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const { hasUnsavedChanges, workbench: currentWorkbench } = useNavigationState();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const licenseAlert = useLicenseAlert();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const [accessMenuOpen, setAccessMenuOpen] = useState(false);
|
||||
const [accessInviteOpen, setAccessInviteOpen] = useState(false);
|
||||
const [selectedAccessFileId, setSelectedAccessFileId] = useState<string | null>(null);
|
||||
const [shareManageOpen, setShareManageOpen] = useState(false);
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const accessButtonRef = useRef<HTMLDivElement>(null);
|
||||
const accessPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const [accessPopoverPosition, setAccessPopoverPosition] = useState({ top: 160, left: 84 });
|
||||
const sharingEnabled = config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = config?.storageShareLinksEnabled === true;
|
||||
const groupSigningEnabled = config?.storageGroupSigningEnabled === true;
|
||||
const isSignWorkbenchActive =
|
||||
currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE ||
|
||||
currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE;
|
||||
const [inviteRows, setInviteRows] = useState<Array<{ id: number; email: string; role: 'editor' | 'commenter' | 'viewer'; error?: string }>>([
|
||||
{ id: Date.now(), email: '', role: 'editor' },
|
||||
]);
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
|
||||
// Sign button state
|
||||
const [signMenuOpen, setSignMenuOpen] = useState(false);
|
||||
const signButtonRef = useRef<HTMLDivElement>(null);
|
||||
const [pendingSignCount, setPendingSignCount] = useState(0);
|
||||
|
||||
// Silently fetch pending sign request count for badge (every 60s)
|
||||
useEffect(() => {
|
||||
if (!groupSigningEnabled) return;
|
||||
const fetchCount = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<SignRequestSummary[]>('/api/v1/security/cert-sign/sign-requests');
|
||||
const pending = response.data.filter(
|
||||
r => r.myStatus !== 'SIGNED' && r.myStatus !== 'DECLINED'
|
||||
).length;
|
||||
setPendingSignCount(pending);
|
||||
} catch { /* silent — avoid noisy background error toasts */ }
|
||||
};
|
||||
fetchCount();
|
||||
const interval = setInterval(fetchCount, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [groupSigningEnabled]);
|
||||
|
||||
// Refresh badge count when popout closes (user may have acted on a request)
|
||||
useEffect(() => {
|
||||
if (!signMenuOpen && groupSigningEnabled) {
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await apiClient.get<SignRequestSummary[]>('/api/v1/security/cert-sign/sign-requests');
|
||||
const pending = response.data.filter(
|
||||
r => r.myStatus !== 'SIGNED' && r.myStatus !== 'DECLINED'
|
||||
).length;
|
||||
setPendingSignCount(pending);
|
||||
} catch { /* silent */ }
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [signMenuOpen, groupSigningEnabled]);
|
||||
|
||||
const configButtonIcon = useConfigButtonIcon();
|
||||
|
||||
const {
|
||||
@@ -57,6 +138,334 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
} = useToursTooltip();
|
||||
|
||||
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||
const hasSelectedFiles = selectedFiles.length > 0;
|
||||
const selectedFileStubs = useMemo(
|
||||
() => selectedFileIds.map((id) => selectors.getStirlingFileStub(id)).filter((x): x is StirlingFileStub => Boolean(x)),
|
||||
[selectedFileIds, selectors, state.files.byId]
|
||||
);
|
||||
const selectedAccessFileStub =
|
||||
selectedFileStubs.find((file) => file.id === selectedAccessFileId) || selectedFileStubs[0];
|
||||
useEffect(() => {
|
||||
if (!hasSelectedFiles) {
|
||||
setAccessMenuOpen(false);
|
||||
setSelectedAccessFileId(null);
|
||||
setAccessInviteOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!selectedAccessFileId || !selectedFiles.some((file) => file.fileId === selectedAccessFileId)) {
|
||||
setSelectedAccessFileId(selectedFiles[0]?.fileId ?? null);
|
||||
}
|
||||
}, [hasSelectedFiles, selectedAccessFileId, selectedFiles]);
|
||||
|
||||
const resetInviteRows = useCallback(() => {
|
||||
setInviteRows([{ id: Date.now(), email: '', role: 'editor' }]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessMenuOpen) return;
|
||||
setAccessInviteOpen(false);
|
||||
setIsInviting(false);
|
||||
resetInviteRows();
|
||||
const updatePosition = () => {
|
||||
const anchor = accessButtonRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const left = isRTL ? Math.max(16, rect.left - 360) : rect.right + 12;
|
||||
const top = Math.max(24, rect.top - 24);
|
||||
setAccessPopoverPosition({ top, left });
|
||||
};
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [accessMenuOpen, isRTL, resetInviteRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessMenuOpen) return;
|
||||
const handleOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (accessPopoverRef.current?.contains(target)) return;
|
||||
if (accessButtonRef.current?.contains(target)) return;
|
||||
|
||||
// Check if click is inside a Mantine dropdown
|
||||
const mantineDropdown = (target as Element).closest?.('.mantine-Combobox-dropdown, .mantine-Popover-dropdown');
|
||||
if (mantineDropdown) return;
|
||||
|
||||
setAccessMenuOpen(false);
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setAccessMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [accessMenuOpen]);
|
||||
|
||||
const shareBaseUrl = useMemo(() => {
|
||||
const frontendUrl = (config?.frontendUrl || '').trim();
|
||||
if (frontendUrl) {
|
||||
try {
|
||||
const parsed = new URL(frontendUrl);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl;
|
||||
return `${normalized}/share/`;
|
||||
}
|
||||
} catch {
|
||||
// invalid URL — fall through to default
|
||||
}
|
||||
}
|
||||
return absoluteWithBasePath('/share/');
|
||||
}, [config?.frontendUrl]);
|
||||
|
||||
const ensureStoredFile = useCallback(async (fileStub: StirlingFileStub): Promise<number> => {
|
||||
const localUpdatedAt = fileStub.createdAt ?? fileStub.lastModified ?? 0;
|
||||
const isUpToDate =
|
||||
Boolean(fileStub.remoteStorageId) &&
|
||||
Boolean(fileStub.remoteStorageUpdatedAt) &&
|
||||
(fileStub.remoteStorageUpdatedAt as number) >= localUpdatedAt;
|
||||
if (isUpToDate && fileStub.remoteStorageId) {
|
||||
return fileStub.remoteStorageId as number;
|
||||
}
|
||||
const originalFileId = (fileStub.originalFileId || fileStub.id) as FileId;
|
||||
const remoteId = fileStub.remoteStorageId as number | undefined;
|
||||
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(
|
||||
originalFileId,
|
||||
remoteId
|
||||
);
|
||||
for (const stub of chain) {
|
||||
actions.updateStirlingFileStub(stub.id, {
|
||||
remoteStorageId: storedId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
});
|
||||
await fileStorage.updateFileMetadata(stub.id, {
|
||||
remoteStorageId: storedId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
});
|
||||
}
|
||||
return storedId;
|
||||
}, [actions]);
|
||||
|
||||
const openShareManage = useCallback(async () => {
|
||||
if (!sharingEnabled) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.sharingDisabled', 'Sharing is disabled.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selectedFileStubs.length > 1) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.selectSingleFile', 'Select a single file to manage sharing.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (selectedAccessFileStub) {
|
||||
await ensureStoredFile(selectedAccessFileStub);
|
||||
}
|
||||
setAccessMenuOpen(false);
|
||||
setShareManageOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file for sharing:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
}
|
||||
}, [ensureStoredFile, selectedAccessFileStub, selectedFileStubs.length, sharingEnabled, t]);
|
||||
|
||||
const handleInviteRowChange = useCallback(
|
||||
(id: number, updates: Partial<{ email: string; role: 'editor' | 'commenter' | 'viewer'; error?: string }>) => {
|
||||
setInviteRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.id !== id) return row;
|
||||
const nextError = Object.prototype.hasOwnProperty.call(updates, 'error')
|
||||
? updates.error
|
||||
: row.error;
|
||||
return { ...row, ...updates, error: nextError };
|
||||
})
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddInviteRow = useCallback(() => {
|
||||
setInviteRows((prev) => [...prev, { id: Date.now(), email: '', role: 'editor' }]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveInviteRow = useCallback((id: number) => {
|
||||
setInviteRows((prev) => (prev.length > 1 ? prev.filter((row) => row.id !== id) : prev));
|
||||
}, []);
|
||||
|
||||
const handleSendInvites = useCallback(async () => {
|
||||
if (!selectedAccessFileStub) return;
|
||||
if (selectedAccessFileStub.remoteOwnedByCurrentUser === false) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextRows = inviteRows.map((row) => {
|
||||
const trimmed = row.email.trim();
|
||||
let error: string | undefined;
|
||||
if (!trimmed) {
|
||||
error = t('storageShare.invalidUsername', 'Enter a valid username or email address.');
|
||||
}
|
||||
return { ...row, email: trimmed, error };
|
||||
});
|
||||
setInviteRows(nextRows);
|
||||
if (nextRows.some((row) => row.error)) {
|
||||
return;
|
||||
}
|
||||
setIsInviting(true);
|
||||
try {
|
||||
const storedId = await ensureStoredFile(selectedAccessFileStub);
|
||||
for (const row of nextRows) {
|
||||
await apiClient.post(`/api/v1/storage/files/${storedId}/shares/users`, {
|
||||
username: row.email.trim(),
|
||||
accessRole: row.role,
|
||||
});
|
||||
}
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.userAdded', 'User added to shared list.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
setAccessInviteOpen(false);
|
||||
resetInviteRows();
|
||||
} catch (error) {
|
||||
console.error('Failed to send invite:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.userAddFailed', 'Unable to share with that user.'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
}
|
||||
}, [ensureStoredFile, inviteRows, resetInviteRows, selectedAccessFileStub, t]);
|
||||
|
||||
const handleCopyShareLink = async () => {
|
||||
if (!selectedAccessFileStub) return;
|
||||
if (!shareLinksEnabled) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.linksDisabled', 'Share links are disabled.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selectedFileStubs.length > 1) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.selectSingleFile', 'Select a single file to copy a link.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!selectedAccessFileStub?.remoteStorageId) {
|
||||
try {
|
||||
await ensureStoredFile(selectedAccessFileStub);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file for sharing:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const storedId = await ensureStoredFile(selectedAccessFileStub);
|
||||
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
|
||||
`/api/v1/storage/files/${storedId}`,
|
||||
{ suppressErrorToast: true }
|
||||
);
|
||||
const links = response.data?.shareLinks ?? [];
|
||||
let token = links[links.length - 1]?.token;
|
||||
if (!token) {
|
||||
const shareResponse = await apiClient.post(`/api/v1/storage/files/${storedId}/shares/links`, {
|
||||
accessRole: 'editor',
|
||||
});
|
||||
token = shareResponse.data?.token;
|
||||
if (token) {
|
||||
actions.updateStirlingFileStub(selectedAccessFileStub.id, { remoteHasShareLinks: true });
|
||||
await fileStorage.updateFileMetadata(selectedAccessFileStub.id, { remoteHasShareLinks: true });
|
||||
}
|
||||
}
|
||||
if (!token) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.failure', 'Unable to generate a share link. Please try again.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.copied', 'Link copied to clipboard'),
|
||||
expandable: false,
|
||||
durationMs: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.copyFailed', 'Copy failed'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Open modal if URL is at /settings/*
|
||||
useEffect(() => {
|
||||
@@ -75,7 +484,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
|
||||
// Helper function to render navigation buttons with URL support
|
||||
const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => {
|
||||
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
const isActive = !isSignWorkbenchActive && isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
|
||||
// Check if this button has URL navigation support
|
||||
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
|
||||
@@ -98,7 +507,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
};
|
||||
|
||||
const buttonStyle = getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
const buttonStyle = isSignWorkbenchActive
|
||||
? { backgroundColor: 'var(--icon-inactive-bg)', color: 'var(--icon-inactive-color)', border: 'none', borderRadius: '0.5rem' }
|
||||
: getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
|
||||
// Render navigation button with conditional URL support
|
||||
return (
|
||||
@@ -215,6 +626,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
} as ButtonConfig])
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -262,6 +674,52 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{hasSelectedFiles && sharingEnabled && (
|
||||
<div ref={accessButtonRef}>
|
||||
<QuickAccessButton
|
||||
icon={<LocalIcon icon="group-rounded" width="1.25rem" height="1.25rem" />}
|
||||
label={t('quickAccess.access', 'Access')}
|
||||
isActive={!isSignWorkbenchActive && accessMenuOpen}
|
||||
onClick={() => {
|
||||
setAccessMenuOpen((prev) => !prev);
|
||||
}}
|
||||
ariaLabel={t('quickAccess.access', 'Access')}
|
||||
dataTestId="access-button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{groupSigningEnabled && (
|
||||
<div ref={signButtonRef} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{pendingSignCount > 0 ? (
|
||||
<Indicator
|
||||
inline
|
||||
label={pendingSignCount}
|
||||
size={14}
|
||||
color="red"
|
||||
position="top-end"
|
||||
offset={4}
|
||||
>
|
||||
<QuickAccessButton
|
||||
icon={<LocalIcon icon="edit-square-rounded" width="1.15rem" height="1.15rem" />}
|
||||
label={t('quickAccess.sign', 'Sign')}
|
||||
isActive={signMenuOpen || isSignWorkbenchActive}
|
||||
onClick={() => setSignMenuOpen((prev) => !prev)}
|
||||
ariaLabel={t('quickAccess.sign', 'Sign')}
|
||||
dataTestId="sign-button"
|
||||
/>
|
||||
</Indicator>
|
||||
) : (
|
||||
<QuickAccessButton
|
||||
icon={<LocalIcon icon="edit-square-rounded" width="1.15rem" height="1.15rem" />}
|
||||
label={t('quickAccess.sign', 'Sign')}
|
||||
isActive={signMenuOpen || isSignWorkbenchActive}
|
||||
onClick={() => setSignMenuOpen((prev) => !prev)}
|
||||
ariaLabel={t('quickAccess.sign', 'Sign')}
|
||||
dataTestId="sign-button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
@@ -390,6 +848,256 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
|
||||
{selectedAccessFileStub && (
|
||||
<ShareManagementModal
|
||||
opened={shareManageOpen}
|
||||
onClose={() => setShareManageOpen(false)}
|
||||
file={selectedAccessFileStub}
|
||||
/>
|
||||
)}
|
||||
{hasSelectedFiles && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={accessPopoverRef}
|
||||
className={`quick-access-popout ${accessMenuOpen ? 'is-open' : ''}`}
|
||||
style={{
|
||||
top: `${accessPopoverPosition.top}px`,
|
||||
left: `${accessPopoverPosition.left}px`,
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label={t('quickAccess.accessPanel', 'Document access')}
|
||||
>
|
||||
<div className="quick-access-popout__card">
|
||||
<div className="quick-access-popout__header">
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-access-popout__back ${accessInviteOpen ? 'is-visible' : ''}`}
|
||||
onClick={() => setAccessInviteOpen(false)}
|
||||
aria-label={t('quickAccess.accessBack', 'Back')}
|
||||
>
|
||||
<LocalIcon icon="arrow-back-rounded" width="1rem" height="1rem" />
|
||||
</button>
|
||||
<div className="quick-access-popout__title">
|
||||
{accessInviteOpen
|
||||
? t('quickAccess.accessInviteTitle', 'Invite People')
|
||||
: t('quickAccess.accessTitle', 'Document Access')}
|
||||
</div>
|
||||
<div className="quick-access-popout__header-actions">
|
||||
{!accessInviteOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__header-action"
|
||||
onClick={() => {
|
||||
void openShareManage();
|
||||
}}
|
||||
aria-label={t('storageShare.manage', 'Manage sharing')}
|
||||
>
|
||||
<LocalIcon icon="settings-rounded" width="1rem" height="1rem" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__header-action"
|
||||
onClick={() => setAccessMenuOpen(false)}
|
||||
aria-label={t('close', 'Close')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`quick-access-popout__body ${accessInviteOpen ? 'is-invite' : ''}`}>
|
||||
<div className="quick-access-popout__panel">
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">
|
||||
{t('quickAccess.accessFileLabel', 'File')}
|
||||
</div>
|
||||
<select
|
||||
className="quick-access-popout__select"
|
||||
value={selectedAccessFileStub?.id ?? ''}
|
||||
onChange={(event) => setSelectedAccessFileId(event.target.value)}
|
||||
>
|
||||
{selectedFileStubs.map((file) => (
|
||||
<option key={file.id} value={file.id}>
|
||||
{file.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__divider" />
|
||||
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">
|
||||
{t('quickAccess.accessGeneral', 'General Access')}
|
||||
</div>
|
||||
<div className="quick-access-popout__row">
|
||||
<div className="quick-access-popout__icon-bubble">
|
||||
<LocalIcon icon="lock-outline" width="1rem" height="1rem" />
|
||||
</div>
|
||||
<div className="quick-access-popout__row-text">
|
||||
<div className="quick-access-popout__row-title">
|
||||
{t('quickAccess.accessRestricted', 'Restricted')}
|
||||
</div>
|
||||
<div className="quick-access-popout__row-subtitle">
|
||||
{t('quickAccess.accessRestrictedHint', 'Only people with access can open')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__divider" />
|
||||
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">
|
||||
{t('quickAccess.accessPeople', 'People with access')}
|
||||
</div>
|
||||
<div className="quick-access-popout__person">
|
||||
<div className="quick-access-popout__avatar">
|
||||
{(selectedAccessFileStub?.remoteOwnerUsername || 'You').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="quick-access-popout__person-text">
|
||||
<div className="quick-access-popout__row-title">
|
||||
{selectedAccessFileStub?.remoteOwnerUsername || t('quickAccess.accessYou', 'You')}
|
||||
</div>
|
||||
<div className="quick-access-popout__row-subtitle">
|
||||
{selectedAccessFileStub?.name ?? t('quickAccess.accessSelectedFile', 'Selected file')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="quick-access-popout__pill">
|
||||
{t('quickAccess.accessOwner', 'Owner')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__panel quick-access-popout__panel--invite">
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">
|
||||
{t('quickAccess.accessInviteTitle', 'Invite People')}
|
||||
</div>
|
||||
</div>
|
||||
{inviteRows.map((row) => (
|
||||
<div key={row.id} className="quick-access-popout__invite-row">
|
||||
<div className="quick-access-popout__input-group">
|
||||
<label className="quick-access-popout__label">
|
||||
{t('quickAccess.accessEmail', 'Email Address')}
|
||||
</label>
|
||||
<input
|
||||
className={`quick-access-popout__input ${row.error ? 'has-error' : ''}`}
|
||||
placeholder={t('quickAccess.accessEmailPlaceholder', '[email protected]')}
|
||||
value={row.email}
|
||||
onChange={(event) =>
|
||||
handleInviteRowChange(row.id, { email: event.target.value, error: undefined })
|
||||
}
|
||||
/>
|
||||
{row.error && (
|
||||
<div className="quick-access-popout__input-error">{row.error}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="quick-access-popout__input-group">
|
||||
<label className="quick-access-popout__label">
|
||||
{t('quickAccess.accessRole', 'Role')}
|
||||
</label>
|
||||
<select
|
||||
className="quick-access-popout__select"
|
||||
value={row.role}
|
||||
onChange={(event) =>
|
||||
handleInviteRowChange(row.id, {
|
||||
role: event.target.value as 'editor' | 'commenter' | 'viewer',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="editor">{t('quickAccess.accessRoleEditor', 'Editor')}</option>
|
||||
<option value="commenter">{t('quickAccess.accessRoleCommenter', 'Commenter')}</option>
|
||||
<option value="viewer">{t('quickAccess.accessRoleViewer', 'Viewer')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__remove"
|
||||
onClick={() => handleRemoveInviteRow(row.id)}
|
||||
disabled={inviteRows.length === 1}
|
||||
aria-label={t('quickAccess.accessRemove', 'Remove')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="0.9rem" height="0.9rem" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__add"
|
||||
onClick={handleAddInviteRow}
|
||||
>
|
||||
<span className="quick-access-popout__add-icon">+</span>
|
||||
{t('quickAccess.accessAddPerson', 'Add another person')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__footer">
|
||||
{accessInviteOpen ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__primary"
|
||||
onClick={() => void handleSendInvites()}
|
||||
disabled={isInviting}
|
||||
>
|
||||
<LocalIcon icon="send-rounded" width="1rem" height="1rem" />
|
||||
{t('quickAccess.accessSendInvite', 'Send Invite')}
|
||||
</button>
|
||||
{shareLinksEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__link"
|
||||
onClick={handleCopyShareLink}
|
||||
>
|
||||
<LocalIcon icon="link-rounded" width="1rem" height="1rem" />
|
||||
{t('quickAccess.accessCopyLink', 'Copy link')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sharingEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__primary"
|
||||
onClick={() => setAccessInviteOpen(true)}
|
||||
>
|
||||
<LocalIcon icon="person-add-rounded" width="1rem" height="1rem" />
|
||||
{t('accessInvite', 'Invite')}
|
||||
</button>
|
||||
)}
|
||||
{shareLinksEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__link"
|
||||
onClick={handleCopyShareLink}
|
||||
>
|
||||
<LocalIcon icon="link-rounded" width="1rem" height="1rem" />
|
||||
{t('quickAccess.accessCopyLink', 'Copy link')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Sign Popover */}
|
||||
<SignPopout
|
||||
isOpen={signMenuOpen}
|
||||
onClose={() => setSignMenuOpen(false)}
|
||||
buttonRef={signButtonRef}
|
||||
isRTL={isRTL}
|
||||
groupSigningEnabled={groupSigningEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { uploadHistoryChain } from '@app/services/serverStorageUpload';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import type { FileId } from '@app/types/file';
|
||||
|
||||
interface ShareFileModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
file: StirlingFileStub;
|
||||
onUploaded?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const ShareFileModal: React.FC<ShareFileModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
file,
|
||||
onUploaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { actions } = useFileActions();
|
||||
const shareLinksEnabled = config?.storageShareLinksEnabled === true;
|
||||
const [isWorking, setIsWorking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||
const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor');
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setIsWorking(false);
|
||||
setErrorMessage(null);
|
||||
setShareToken(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setShareRole('editor');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const shareUrl = useMemo(() => {
|
||||
if (!shareToken) return '';
|
||||
const frontendUrl = (config?.frontendUrl || '').trim();
|
||||
if (frontendUrl) {
|
||||
try {
|
||||
const parsed = new URL(frontendUrl);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl;
|
||||
return `${normalized}/share/${shareToken}`;
|
||||
}
|
||||
} catch {
|
||||
// invalid URL — fall through to default
|
||||
}
|
||||
}
|
||||
return absoluteWithBasePath(`/share/${shareToken}`);
|
||||
}, [config?.frontendUrl, shareToken]);
|
||||
|
||||
const createShareLink = useCallback(async (storedFileId: number) => {
|
||||
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
|
||||
accessRole: shareRole,
|
||||
});
|
||||
return response.data as { token?: string };
|
||||
}, [shareRole]);
|
||||
|
||||
const handleGenerateLink = useCallback(async () => {
|
||||
if (!shareLinksEnabled) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.linksDisabled', 'Share links are disabled.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsWorking(true);
|
||||
setErrorMessage(null);
|
||||
setShareToken(null);
|
||||
|
||||
try {
|
||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||
const isUpToDate =
|
||||
Boolean(file.remoteStorageId) &&
|
||||
Boolean(file.remoteStorageUpdatedAt) &&
|
||||
(file.remoteStorageUpdatedAt as number) >= localUpdatedAt;
|
||||
|
||||
let storedId = file.remoteStorageId;
|
||||
|
||||
if (!isUpToDate) {
|
||||
const originalFileId = (file.originalFileId || file.id) as FileId;
|
||||
const remoteId = file.remoteStorageId;
|
||||
const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain(
|
||||
originalFileId,
|
||||
remoteId
|
||||
);
|
||||
storedId = newStoredId;
|
||||
|
||||
for (const stub of chain) {
|
||||
actions.updateStirlingFileStub(stub.id, {
|
||||
remoteStorageId: newStoredId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteHasShareLinks: true,
|
||||
});
|
||||
await fileStorage.updateFileMetadata(stub.id, {
|
||||
remoteStorageId: newStoredId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteHasShareLinks: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!storedId) {
|
||||
throw new Error('Missing stored file ID for sharing.');
|
||||
}
|
||||
const shareResponse = await createShareLink(storedId);
|
||||
setShareToken(shareResponse.token ?? null);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.generated', 'Share link generated'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
if (storedId) {
|
||||
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true });
|
||||
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true });
|
||||
}
|
||||
if (onUploaded) {
|
||||
await onUploaded();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate share link:', error);
|
||||
setErrorMessage(
|
||||
t('storageShare.failure', 'Unable to generate a share link. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
}, [actions, createShareLink, file, onUploaded, shareLinksEnabled, t]);
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.copied', 'Link copied to clipboard'),
|
||||
expandable: false,
|
||||
durationMs: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.copyFailed', 'Copy failed'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
}, [shareUrl, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
title={t('storageShare.title', 'Share File')}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
size="lg"
|
||||
overlayProps={{ blur: 6 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'storageShare.description',
|
||||
'Create a share link for this file. Signed-in users with the link can access it.'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageShare.fileLabel', 'File')}: {file.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert color="red" title={t('storageShare.errorTitle', 'Share failed')}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{!shareLinksEnabled && (
|
||||
<Alert color="yellow" title={t('storageShare.linksDisabled', 'Share links are disabled.')}>
|
||||
{t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shareUrl && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
label={t('storageShare.linkLabel', 'Share link')}
|
||||
rightSection={
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{t('storageShare.copy', 'Copy')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('storageShare.linkAccessTitle', 'Share link access')}
|
||||
</Text>
|
||||
<Select
|
||||
label={t('storageShare.roleLabel', 'Role')}
|
||||
value={shareRole}
|
||||
onChange={(value) => setShareRole((value as typeof shareRole) || 'editor')}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
|
||||
data={[
|
||||
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
|
||||
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
|
||||
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
|
||||
]}
|
||||
/>
|
||||
{shareRole === 'commenter' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={isWorking}>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<LinkIcon style={{ fontSize: 18 }} />}
|
||||
onClick={handleGenerateLink}
|
||||
loading={isWorking}
|
||||
disabled={!shareLinksEnabled}
|
||||
>
|
||||
{t('storageShare.generate', 'Generate Link')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareFileModal;
|
||||
@@ -0,0 +1,783 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Alert,
|
||||
TextInput,
|
||||
Badge,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
ScrollArea,
|
||||
Select,
|
||||
} from '@mantine/core';
|
||||
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
|
||||
interface ShareLinkResponse {
|
||||
token: string;
|
||||
accessRole?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface ShareLinkAccessResponse {
|
||||
username?: string | null;
|
||||
accessType?: string | null;
|
||||
accessedAt?: string | null;
|
||||
}
|
||||
|
||||
interface StoredFileResponse {
|
||||
shareLinks?: ShareLinkResponse[];
|
||||
ownedByCurrentUser?: boolean;
|
||||
sharedWithUsers?: string[];
|
||||
sharedUsers?: Array<{ username: string; accessRole?: string | null }>;
|
||||
}
|
||||
|
||||
interface ShareManagementModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
file,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const sharingEnabled = config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = config?.storageShareLinksEnabled === true;
|
||||
const { actions } = useFileActions();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [shareLinks, setShareLinks] = useState<ShareLinkResponse[]>([]);
|
||||
const [activityMap, setActivityMap] = useState<Record<string, ShareLinkAccessResponse[]>>({});
|
||||
const [sharedUsers, setSharedUsers] = useState<Array<{ username: string; accessRole?: string | null }>>([]);
|
||||
const [shareUsername, setShareUsername] = useState('');
|
||||
const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor');
|
||||
const [showEmailWarning, setShowEmailWarning] = useState(false);
|
||||
const [selectedActivityToken, setSelectedActivityToken] = useState<string | null>(null);
|
||||
const [confirmRevokeToken, setConfirmRevokeToken] = useState<string | null>(null);
|
||||
const [confirmRemoveUser, setConfirmRemoveUser] = useState<string | null>(null);
|
||||
|
||||
const normalizedShareUsername = shareUsername.trim();
|
||||
const lowerShareUsername = normalizedShareUsername.toLowerCase();
|
||||
const isEmailInput = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedShareUsername);
|
||||
const isSimpleUsername = /^[A-Za-z0-9@._+-]{3,50}$/.test(normalizedShareUsername);
|
||||
const isReservedUsername =
|
||||
lowerShareUsername === 'all_users' || lowerShareUsername === 'anonymoususer';
|
||||
const isValidShareUsername =
|
||||
normalizedShareUsername.length > 0 && !isReservedUsername && (isEmailInput || isSimpleUsername);
|
||||
const shareUsernameError =
|
||||
normalizedShareUsername.length > 0 && !isValidShareUsername
|
||||
? t(
|
||||
'storageShare.invalidUsername',
|
||||
'Enter a valid username or email address.'
|
||||
)
|
||||
: null;
|
||||
|
||||
const shareBaseUrl = useMemo(() => {
|
||||
const frontendUrl = (config?.frontendUrl || '').trim();
|
||||
if (frontendUrl) {
|
||||
try {
|
||||
const parsed = new URL(frontendUrl);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl;
|
||||
return `${normalized}/share/`;
|
||||
}
|
||||
} catch {
|
||||
// invalid URL — fall through to default
|
||||
}
|
||||
}
|
||||
return absoluteWithBasePath('/share/');
|
||||
}, [config?.frontendUrl]);
|
||||
|
||||
const loadShareLinks = useCallback(async () => {
|
||||
if (!file.remoteStorageId) return;
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await apiClient.get<StoredFileResponse>(
|
||||
`/api/v1/storage/files/${file.remoteStorageId}`,
|
||||
{ suppressErrorToast: true }
|
||||
);
|
||||
const links = response.data?.shareLinks ?? [];
|
||||
const users =
|
||||
response.data?.sharedUsers ??
|
||||
(response.data?.sharedWithUsers ?? []).map((username) => ({
|
||||
username,
|
||||
accessRole: 'editor',
|
||||
}));
|
||||
setShareLinks(links);
|
||||
setSharedUsers(users);
|
||||
} catch (error) {
|
||||
console.error('Failed to load share links:', error);
|
||||
setErrorMessage(
|
||||
t('storageShare.manageLoadFailed', 'Unable to load share links.')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [actions, file.remoteStorageId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
loadShareLinks();
|
||||
setActivityMap({});
|
||||
setShareRole('editor');
|
||||
setSelectedActivityToken(null);
|
||||
}
|
||||
}, [opened, loadShareLinks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setSelectedActivityToken(null);
|
||||
return;
|
||||
}
|
||||
if (shareLinks.length === 0) {
|
||||
setSelectedActivityToken(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedActivityToken || !shareLinks.some((link) => link.token === selectedActivityToken)) {
|
||||
setSelectedActivityToken(shareLinks[0].token);
|
||||
}
|
||||
}, [opened, selectedActivityToken, shareLinks]);
|
||||
|
||||
const createShareLink = useCallback(
|
||||
async () => {
|
||||
if (!file.remoteStorageId) return;
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/api/v1/storage/files/${file.remoteStorageId}/shares/links`,
|
||||
{ accessRole: shareRole }
|
||||
);
|
||||
const token = response.data?.token as string | undefined;
|
||||
if (token) {
|
||||
setShareLinks((prev) => [
|
||||
...prev,
|
||||
{
|
||||
token,
|
||||
accessRole: shareRole,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true });
|
||||
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true });
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.generated', 'Share link generated'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create share link:', error);
|
||||
setErrorMessage(
|
||||
t('storageShare.failure', 'Unable to generate a share link. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[actions, file.id, file.remoteStorageId, shareRole, t]
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(async (token: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.copied', 'Link copied to clipboard'),
|
||||
expandable: false,
|
||||
durationMs: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link:', error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('storageShare.copyFailed', 'Copy failed'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
}, [shareBaseUrl, t]);
|
||||
|
||||
const handleRevokeLink = useCallback(async (token: string) => {
|
||||
if (!file.remoteStorageId) return;
|
||||
setIsLoading(true);
|
||||
setConfirmRevokeToken(null);
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/storage/files/${file.remoteStorageId}/shares/links/${token}`);
|
||||
// Compute before setShareLinks so we don't read stale closure state after the update
|
||||
const nextHasLinks = shareLinks.filter((link) => link.token !== token).length > 0;
|
||||
setShareLinks((prev) => prev.filter((link) => link.token !== token));
|
||||
setActivityMap((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[token];
|
||||
return updated;
|
||||
});
|
||||
setSelectedActivityToken((prev) => (prev === token ? null : prev));
|
||||
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: nextHasLinks });
|
||||
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: nextHasLinks });
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.revoked', 'Share link removed'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke share link:', error);
|
||||
setErrorMessage(t('storageShare.revokeFailed', 'Unable to remove the share link.'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [actions, file.remoteStorageId, shareLinks, t]);
|
||||
|
||||
const handleLoadActivity = useCallback(async (token: string) => {
|
||||
if (!file.remoteStorageId) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get<ShareLinkAccessResponse[]>(
|
||||
`/api/v1/storage/files/${file.remoteStorageId}/shares/links/${token}/accesses`,
|
||||
{ suppressErrorToast: true }
|
||||
);
|
||||
setActivityMap((prev) => ({
|
||||
...prev,
|
||||
[token]: response.data ?? [],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load share activity:', error);
|
||||
setErrorMessage(t('storageShare.accessFailed', 'Unable to load activity.'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [file.remoteStorageId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedActivityToken) return;
|
||||
if (activityMap[selectedActivityToken] === undefined) {
|
||||
void handleLoadActivity(selectedActivityToken);
|
||||
}
|
||||
}, [activityMap, handleLoadActivity, selectedActivityToken]);
|
||||
|
||||
const handleAddUser = useCallback(async (forceEmailConfirm = false) => {
|
||||
if (!file.remoteStorageId) return;
|
||||
const trimmed = shareUsername.trim();
|
||||
if (!trimmed) return;
|
||||
if (!isValidShareUsername) {
|
||||
return;
|
||||
}
|
||||
if (isEmailInput && !forceEmailConfirm) {
|
||||
setShowEmailWarning(true);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/users`, {
|
||||
username: trimmed,
|
||||
accessRole: shareRole,
|
||||
});
|
||||
setSharedUsers((prev) => {
|
||||
if (prev.some((user) => user.username === trimmed)) {
|
||||
return prev.map((user) =>
|
||||
user.username === trimmed ? { ...user, accessRole: shareRole } : user
|
||||
);
|
||||
}
|
||||
return [...prev, { username: trimmed, accessRole: shareRole }].sort((a, b) =>
|
||||
a.username.localeCompare(b.username)
|
||||
);
|
||||
});
|
||||
setShareUsername('');
|
||||
setShowEmailWarning(false);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.userAdded', 'User added to shared list.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to share with user:', error);
|
||||
setErrorMessage(
|
||||
t('storageShare.userAddFailed', 'Unable to share with that user.')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [file.remoteStorageId, isEmailInput, isValidShareUsername, shareRole, shareUsername, t]);
|
||||
|
||||
const handleUpdateUserRole = useCallback(
|
||||
async (username: string, nextRole: 'editor' | 'commenter' | 'viewer') => {
|
||||
if (!file.remoteStorageId) return;
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/users`, {
|
||||
username,
|
||||
accessRole: nextRole,
|
||||
});
|
||||
setSharedUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.username === username ? { ...user, accessRole: nextRole } : user
|
||||
)
|
||||
);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.userAdded', 'User added to shared list.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update shared user role:', error);
|
||||
setErrorMessage(
|
||||
t('storageShare.userAddFailed', 'Unable to share with that user.')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[file.remoteStorageId, t]
|
||||
);
|
||||
|
||||
const handleRemoveUser = useCallback(async (username: string) => {
|
||||
if (!file.remoteStorageId) return;
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
setConfirmRemoveUser(null);
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/api/v1/storage/files/${file.remoteStorageId}/shares/users/${encodeURIComponent(
|
||||
username
|
||||
)}`
|
||||
);
|
||||
setSharedUsers((prev) => prev.filter((user) => user.username !== username));
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageShare.userRemoved', 'User removed from shared list.'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to remove shared user:', error);
|
||||
setErrorMessage(
|
||||
t('storageShare.userRemoveFailed', 'Unable to remove that user.')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [file.remoteStorageId, t]);
|
||||
|
||||
const selectedActivity = selectedActivityToken
|
||||
? activityMap[selectedActivityToken]
|
||||
: undefined;
|
||||
const selectedLink = selectedActivityToken
|
||||
? shareLinks.find((link) => link.token === selectedActivityToken)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
title={t('storageShare.manageTitle', 'Manage Sharing')}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
size="xl"
|
||||
overlayProps={{ blur: 8 }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageShare.manageDescription', 'Create and manage links to share this file.')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t('storageShare.fileLabel', 'File')}: <Text span fw={600}>{file.name}</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert color="red" title={t('storageShare.errorTitle', 'Sharing error')}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{!sharingEnabled && (
|
||||
<Alert color="yellow" title={t('storageShare.sharingDisabled', 'Sharing is disabled.')}>
|
||||
{t('storageShare.sharingDisabledBody', 'Sharing has been disabled by your server settings.')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
<Stack gap="lg">
|
||||
{shareLinksEnabled && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('storageShare.linkAccessTitle', 'Share link access')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Select
|
||||
label={t('storageShare.roleLabel', 'Role')}
|
||||
value={shareRole}
|
||||
onChange={(value) => setShareRole((value as typeof shareRole) || 'editor')}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
|
||||
data={[
|
||||
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
|
||||
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
|
||||
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
|
||||
]}
|
||||
/>
|
||||
{shareRole === 'commenter' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
leftSection={<LinkIcon style={{ fontSize: 18 }} />}
|
||||
onClick={() => createShareLink()}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t('storageShare.generate', 'Generate Link')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('storageShare.sharedUsersTitle', 'Shared users')}
|
||||
</Text>
|
||||
<Group align="flex-end" gap="sm">
|
||||
<TextInput
|
||||
label={t('storageShare.usernameLabel', 'Username or email')}
|
||||
placeholder={t('storageShare.usernamePlaceholder', 'Enter a username or email')}
|
||||
value={shareUsername}
|
||||
onChange={(event) => {
|
||||
setShareUsername(event.currentTarget.value);
|
||||
setShowEmailWarning(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void handleAddUser();
|
||||
}
|
||||
}}
|
||||
disabled={!sharingEnabled || isLoading}
|
||||
error={shareUsernameError}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleAddUser()}
|
||||
disabled={!sharingEnabled || isLoading || !normalizedShareUsername || !!shareUsernameError}
|
||||
>
|
||||
{t('storageShare.addUser', 'Add')}
|
||||
</Button>
|
||||
</Group>
|
||||
{showEmailWarning && (
|
||||
<Alert color="yellow" title={t('storageShare.emailWarningTitle', 'Email address')} variant="light">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'storageShare.emailWarningBody',
|
||||
'This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.'
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setShowEmailWarning(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleAddUser(true)}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t('storageShare.emailWarningConfirm', 'Share anyway')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{sharedUsers.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageShare.noSharedUsers', 'No users have access yet.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{sharedUsers.map((user) => (
|
||||
<Group key={user.username} justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{user.username}</Text>
|
||||
{user.accessRole === 'commenter' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap="xs" align="center">
|
||||
<Select
|
||||
value={user.accessRole ?? 'editor'}
|
||||
onChange={(value) => {
|
||||
const nextRole = (value as 'editor' | 'commenter' | 'viewer') || 'editor';
|
||||
void handleUpdateUserRole(user.username, nextRole);
|
||||
}}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
|
||||
data={[
|
||||
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
|
||||
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
|
||||
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
{confirmRemoveUser === user.username ? (
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="filled"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => { void handleRemoveUser(user.username); }}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t('confirm', 'Confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={() => setConfirmRemoveUser(null)}
|
||||
>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
color="red"
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={() => setConfirmRemoveUser(user.username)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('storageShare.removeUser', 'Remove')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{shareLinksEnabled && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('storageShare.linkLabel', 'Share link')}
|
||||
</Text>
|
||||
{shareLinks.length > 0 && (
|
||||
<Badge variant="light" color="blue">
|
||||
{shareLinks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{shareLinks.length === 0 && !isLoading && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageShare.noLinks', 'No active share links yet.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{shareLinks.map((link) => {
|
||||
const activity = activityMap[link.token];
|
||||
const viewCount = activity?.filter((entry) => entry.accessType === 'VIEW').length ?? 0;
|
||||
const downloadCount = activity?.filter((entry) => entry.accessType === 'DOWNLOAD').length ?? 0;
|
||||
const lastAccessedAt = activity?.[0]?.accessedAt;
|
||||
const isSelected = selectedActivityToken === link.token;
|
||||
return (
|
||||
<Paper key={link.token} withBorder radius="md" p="sm">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
readOnly
|
||||
value={`${shareBaseUrl}${link.token}`}
|
||||
label={t('storageShare.linkLabel', 'Share link')}
|
||||
rightSection={
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
|
||||
onClick={() => handleCopyLink(link.token)}
|
||||
>
|
||||
{t('storageShare.copy', 'Copy')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
{link.accessRole && (
|
||||
<Badge variant="light" color="gray">
|
||||
{link.accessRole === 'editor'
|
||||
? t('storageShare.roleEditor', 'Editor')
|
||||
: link.accessRole === 'commenter'
|
||||
? t('storageShare.roleCommenter', 'Commenter')
|
||||
: t('storageShare.roleViewer', 'Viewer')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="sm" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('storageShare.viewsCount', 'Views: {{count}}', { count: viewCount })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('storageShare.downloadsCount', 'Downloads: {{count}}', { count: downloadCount })}
|
||||
</Text>
|
||||
{lastAccessedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('storageShare.lastAccessed', 'Last accessed')}: {new Date(lastAccessedAt).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant={isSelected ? 'filled' : 'light'}
|
||||
size="xs"
|
||||
leftSection={<HistoryIcon style={{ fontSize: 16 }} />}
|
||||
onClick={() =>
|
||||
setSelectedActivityToken((prev) => (prev === link.token ? null : link.token))
|
||||
}
|
||||
>
|
||||
{isSelected
|
||||
? t('storageShare.hideActivity', 'Hide activity')
|
||||
: t('storageShare.viewActivity', 'View activity')}
|
||||
</Button>
|
||||
{confirmRevokeToken === link.token ? (
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="filled"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => { void handleRevokeLink(link.token); }}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t('confirm', 'Confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={() => setConfirmRevokeToken(null)}
|
||||
>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
color="red"
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={() => setConfirmRevokeToken(link.token)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('storageShare.removeLink', 'Remove link')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{shareLinksEnabled && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('storageShare.viewActivity', 'View activity')}
|
||||
</Text>
|
||||
{selectedLink && selectedLink.accessRole && (
|
||||
<Badge variant="light" color="gray">
|
||||
{selectedLink.accessRole === 'editor'
|
||||
? t('storageShare.roleEditor', 'Editor')
|
||||
: selectedLink.accessRole === 'commenter'
|
||||
? t('storageShare.roleCommenter', 'Commenter')
|
||||
: t('storageShare.roleViewer', 'Viewer')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{!selectedActivityToken && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageShare.noActivity', 'No activity yet.')}
|
||||
</Text>
|
||||
)}
|
||||
{selectedActivityToken && (
|
||||
<ScrollArea h={360} offsetScrollbars>
|
||||
<Stack gap="xs">
|
||||
{(selectedActivity ?? []).length > 0 ? (
|
||||
selectedActivity?.map((entry, index) => (
|
||||
<Paper key={`${selectedActivityToken}-${index}`} radius="md" p="xs" withBorder>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{entry.accessedAt
|
||||
? new Date(entry.accessedAt).toLocaleString()
|
||||
: t('unknown', 'Unknown')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{entry.username || t('storageShare.unknownUser', 'Unknown user')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="sm" variant="light">
|
||||
{entry.accessType === 'VIEW'
|
||||
? t('storageShare.viewed', 'Viewed')
|
||||
: entry.accessType === 'DOWNLOAD'
|
||||
? t('storageShare.downloaded', 'Downloaded')
|
||||
: t('storageShare.accessed', 'Accessed')}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageShare.noActivity', 'No activity yet.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareManagementModal;
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { alert } from '@app/components/toast';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { uploadHistoryChain } from '@app/services/serverStorageUpload';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import type { FileId } from '@app/types/file';
|
||||
|
||||
interface UploadToServerModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
file: StirlingFileStub;
|
||||
onUploaded?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
file,
|
||||
onUploaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions } = useFileActions();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setIsUploading(false);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
setIsUploading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const originalFileId = (file.originalFileId || file.id) as FileId;
|
||||
const remoteId = file.remoteStorageId;
|
||||
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(
|
||||
originalFileId,
|
||||
remoteId
|
||||
);
|
||||
|
||||
for (const stub of chain) {
|
||||
actions.updateStirlingFileStub(stub.id, {
|
||||
remoteStorageId: storedId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
});
|
||||
await fileStorage.updateFileMetadata(stub.id, {
|
||||
remoteStorageId: storedId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
});
|
||||
}
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('storageUpload.success', 'Uploaded to server'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
if (onUploaded) {
|
||||
await onUploaded();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file to server:', error);
|
||||
setErrorMessage(
|
||||
t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.')
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [actions, file, onClose, onUploaded, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
title={t('storageUpload.title', 'Upload to Server')}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'storageUpload.description',
|
||||
'This uploads the current file to server storage for your own access.'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('storageUpload.fileLabel', 'File')}: {file.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'storageUpload.hint',
|
||||
'Public links and access modes are controlled by your server settings.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert color="red" title={t('storageUpload.errorTitle', 'Upload failed')}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={isUploading}>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
|
||||
onClick={handleUpload}
|
||||
loading={isUploading}
|
||||
>
|
||||
{file.remoteStorageId
|
||||
? t('storageUpload.updateButton', 'Update on Server')
|
||||
: t('storageUpload.uploadButton', 'Upload to Server')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadToServerModal;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MultiSelect, Loader, Text, Button, Stack } from '@mantine/core';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { UserSummary } from '@app/types/signingSession';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface UserSelectorProps {
|
||||
value: number[];
|
||||
onChange: (userIds: number[]) => void;
|
||||
placeholder?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type SelectItem = { value: string; label: string };
|
||||
type GroupedData = { group: string; items: SelectItem[] };
|
||||
|
||||
const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = false }: UserSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [selectData, setSelectData] = useState<GroupedData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stringValue, setStringValue] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/user/users');
|
||||
console.log('Users API response:', response.data);
|
||||
const fetchedUsers = response.data || [];
|
||||
|
||||
// Process selectData inside useEffect - group by team
|
||||
const usersByTeam: Record<string, SelectItem[]> = {};
|
||||
const currentUserId = user?.id ? parseInt(user.id, 10) : null;
|
||||
|
||||
fetchedUsers
|
||||
.filter((u: UserSummary) => u && u.userId && u.username)
|
||||
.filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user
|
||||
.filter((u: UserSummary) => u.teamName?.toLowerCase() !== 'internal') // Exclude internal users
|
||||
.forEach((user: UserSummary) => {
|
||||
const teamName = user.teamName || t('certSign.collab.userSelector.noTeam', 'No Team');
|
||||
if (!usersByTeam[teamName]) {
|
||||
usersByTeam[teamName] = [];
|
||||
}
|
||||
const displayName = user.displayName || user.username || 'Unknown';
|
||||
const username = user.username || 'unknown';
|
||||
const label =
|
||||
displayName !== username ? `${displayName} (@${username})` : displayName;
|
||||
usersByTeam[teamName].push({
|
||||
value: String(user.userId),
|
||||
label,
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to Mantine's grouped format
|
||||
const processed: GroupedData[] = Object.entries(usersByTeam).map(([teamName, items]) => ({
|
||||
group: teamName,
|
||||
items: items.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}));
|
||||
|
||||
console.log('Processed selectData:', processed);
|
||||
setSelectData(processed);
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.userSelector.loadError', 'Failed to load users'),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, [t, user]);
|
||||
|
||||
// Process stringValue when value prop changes
|
||||
useEffect(() => {
|
||||
const safeValue = Array.isArray(value) ? value : [];
|
||||
const result = safeValue.map((id) => (id != null ? id.toString() : '')).filter(Boolean);
|
||||
console.log('stringValue for MultiSelect:', result);
|
||||
setStringValue(result);
|
||||
}, [value]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader size="sm" />;
|
||||
}
|
||||
|
||||
// No users available — prompt to invite
|
||||
if (!selectData || selectData.length === 0) {
|
||||
return (
|
||||
<Stack gap="xs" align="flex-start">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('certSign.collab.userSelector.noUsers', 'No other users found.')}
|
||||
</Text>
|
||||
<Button size="xs" variant="light" onClick={() => navigate('/settings/people')}>
|
||||
{t('certSign.collab.userSelector.inviteUsers', 'Add Users')}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
data={selectData}
|
||||
value={stringValue}
|
||||
onChange={(selectedIds) => {
|
||||
const parsedIds = selectedIds
|
||||
.map((id) => parseInt(id, 10))
|
||||
.filter((id) => !isNaN(id));
|
||||
onChange(parsedIds);
|
||||
}}
|
||||
placeholder={placeholder || t('certSign.collab.userSelector.placeholder', 'Select users...')}
|
||||
searchable
|
||||
clearable
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
maxDropdownHeight={300}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSelector;
|
||||
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
|
||||
'adminAudit',
|
||||
'adminUsage',
|
||||
'adminEndpoints',
|
||||
'adminStorageSharing',
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -264,3 +264,695 @@
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0.75rem auto 0;
|
||||
}
|
||||
|
||||
/* Access popout */
|
||||
.quick-access-popout {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 21rem;
|
||||
max-height: calc(100vh - 4rem);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-8px) scale(0.98);
|
||||
transition: opacity 160ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.quick-access-popout.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.quick-access-popout__card {
|
||||
background: var(--bg-raised);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: 0 18px 36px color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.quick-access-popout__header {
|
||||
background: color-mix(in srgb, var(--text-primary) 18%, var(--bg-toolbar));
|
||||
color: var(--text-primary);
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem 1fr 2.25rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="light"] .quick-access-popout__header {
|
||||
background: #3c4c6f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quick-access-popout__title {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.quick-access-popout__header-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.9;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-access-popout__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__back {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.quick-access-popout__back.is-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.quick-access-popout__body {
|
||||
display: flex;
|
||||
width: 200%;
|
||||
transform: translateX(0%);
|
||||
transition: transform 240ms ease;
|
||||
}
|
||||
|
||||
.quick-access-popout__body.is-invite {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Tab-based positioning for 3 panels */
|
||||
.quick-access-popout__body.tab-access,
|
||||
.quick-access-popout__body.tab-requestSignatures,
|
||||
.quick-access-popout__body.tab-signRequests {
|
||||
width: 300%;
|
||||
}
|
||||
|
||||
.quick-access-popout__body.tab-access {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
.quick-access-popout__body.tab-requestSignatures {
|
||||
transform: translateX(-33.333%);
|
||||
}
|
||||
|
||||
.quick-access-popout__body.tab-signRequests {
|
||||
transform: translateX(-66.666%);
|
||||
}
|
||||
|
||||
.quick-access-popout__panel {
|
||||
width: 50%;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* 3-panel layout for tab system */
|
||||
.quick-access-popout__body.tab-access .quick-access-popout__panel,
|
||||
.quick-access-popout__body.tab-requestSignatures .quick-access-popout__panel,
|
||||
.quick-access-popout__body.tab-signRequests .quick-access-popout__panel {
|
||||
width: 33.333%;
|
||||
}
|
||||
|
||||
.quick-access-popout__panel--invite {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.quick-access-popout__section {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__select,
|
||||
.quick-access-popout__input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick-access-popout__input-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__input.has-error {
|
||||
border-color: var(--color-yellow-400);
|
||||
background: color-mix(in srgb, var(--color-yellow-200) 18%, var(--bg-surface));
|
||||
}
|
||||
|
||||
.quick-access-popout__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quick-access-popout__invite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__remove {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-access-popout__input-error {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-red-500);
|
||||
}
|
||||
|
||||
.quick-access-popout__divider {
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.quick-access-popout__row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-access-popout__row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-access-popout__icon-bubble {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--btn-open-file) 18%, var(--bg-surface));
|
||||
color: var(--btn-open-file);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quick-access-popout__row-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quick-access-popout__row-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quick-access-popout__person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__avatar {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--btn-open-file) 20%, var(--bg-surface));
|
||||
color: var(--btn-open-file);
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quick-access-popout__person-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-access-popout__pill {
|
||||
background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-muted));
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.quick-access-popout__add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--btn-open-file);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-access-popout__add[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.quick-access-popout__add-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__hint {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__warning {
|
||||
margin-top: 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-yellow-100) 35%, var(--bg-surface));
|
||||
border: 1px solid var(--color-yellow-300);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
color: var(--color-yellow-700);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__warning-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__warning-body {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__warning-list {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-yellow-700);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__warning-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__secondary {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--color-yellow-300);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
color: var(--color-yellow-700);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-access-popout__footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
background: var(--bg-muted);
|
||||
border-top: 1px solid var(--border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-access-popout__error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-red-500);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__primary {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--btn-open-file);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-access-popout__primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.quick-access-popout__link {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-surface);
|
||||
color: var(--btn-open-file);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Quick sign shortcuts */
|
||||
.quick-access-popout__quick-sign {
|
||||
padding: 0.75rem 1rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-access-popout__section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__quick-sign-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__quick-sign-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
transition: background 120ms, border-color 120ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-access-popout__quick-sign-btn:hover {
|
||||
background: color-mix(in srgb, var(--btn-open-file) 12%, var(--bg-muted));
|
||||
border-color: color-mix(in srgb, var(--btn-open-file) 40%, var(--border-default));
|
||||
}
|
||||
|
||||
/* Tab navigation */
|
||||
.quick-access-popout__tab-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
margin-bottom: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-access-popout__tab-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.quick-access-popout__tab-button.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--btn-open-file);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quick-access-popout__tab-button:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Sign request row styling */
|
||||
.quick-access-popout__sign-request-row {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-surface);
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-access-popout__sign-request-row:hover {
|
||||
background: color-mix(in srgb, var(--text-primary) 5%, var(--bg-surface));
|
||||
}
|
||||
|
||||
.quick-access-popout__sign-request-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-access-popout__sign-request-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sign popover height constraints */
|
||||
.quick-access-sign-popout {
|
||||
width: 26rem;
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-access-popout__section-label--padded {
|
||||
padding: 0.5rem 1rem 0;
|
||||
}
|
||||
|
||||
.quick-access-popout__section-label--row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quick-access-popout__section-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 40%, transparent);
|
||||
border-radius: 5px;
|
||||
color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms, color 120ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-access-popout__section-action:hover {
|
||||
background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 28%, transparent);
|
||||
border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__tab-nav {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__quick-sign {
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__quick-sign-actions {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Search + filter bar */
|
||||
.quick-access-popout__search-filter {
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__search {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quick-access-popout__search:focus {
|
||||
border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
|
||||
}
|
||||
|
||||
.quick-access-popout__search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quick-access-popout__filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.quick-access-popout__filter-chip {
|
||||
padding: 0.2rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms, color 120ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-access-popout__filter-chip:hover {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick-access-popout__filter-chip.is-active {
|
||||
background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, transparent);
|
||||
border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
|
||||
color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sign popover 3-panel layout */
|
||||
.quick-access-sign-popout .quick-access-popout__body {
|
||||
width: 300%;
|
||||
transform: translateX(0%);
|
||||
transition: transform 240ms ease;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__body.sign-tab-requestSignatures {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__body.sign-tab-signRequests {
|
||||
transform: translateX(-33.333%);
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__body.sign-tab-mySessions {
|
||||
transform: translateX(-66.666%);
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__panel {
|
||||
width: 33.333%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.quick-access-popout {
|
||||
left: 5.25rem !important;
|
||||
right: 1rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phone: Drawer takes over SignPopout entirely.
|
||||
This is a defensive fallback in case the portal branch ever renders on phone. */
|
||||
@media (max-width: 768px) {
|
||||
.quick-access-sign-popout {
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.quick-access-sign-popout .quick-access-popout__card {
|
||||
border-radius: 0;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface QuickAccessButtonProps {
|
||||
component?: 'a' | 'button';
|
||||
dataTestId?: string;
|
||||
dataTour?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const QuickAccessButton: React.FC<QuickAccessButtonProps> = ({
|
||||
@@ -34,6 +35,7 @@ const QuickAccessButton: React.FC<QuickAccessButtonProps> = ({
|
||||
component = 'button',
|
||||
dataTestId,
|
||||
dataTour,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const buttonSize = size || (isActive ? 'lg' : 'md');
|
||||
const bgColor = backgroundColor || (isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)');
|
||||
@@ -57,12 +59,15 @@ const QuickAccessButton: React.FC<QuickAccessButtonProps> = ({
|
||||
{...actionIconProps}
|
||||
size={buttonSize}
|
||||
variant="subtle"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
className={className || (isActive ? 'activeIconScale' : '')}
|
||||
data-testid={dataTestId}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader, Center, Text, Badge } from '@mantine/core';
|
||||
|
||||
interface SessionItem {
|
||||
itemType: 'signRequest' | 'mySession';
|
||||
sessionId: string;
|
||||
documentName: string;
|
||||
createdAt: string;
|
||||
myStatus?: string;
|
||||
ownerUsername?: string;
|
||||
ownerEmail?: string;
|
||||
dueDate?: string;
|
||||
finalized?: boolean;
|
||||
signedCount?: number;
|
||||
participantCount?: number;
|
||||
}
|
||||
|
||||
interface ActiveSessionsPanelProps {
|
||||
sessions: SessionItem[];
|
||||
loading: boolean;
|
||||
onSessionClick: (session: SessionItem) => void;
|
||||
}
|
||||
|
||||
const ActiveSessionsPanel = ({
|
||||
sessions,
|
||||
loading,
|
||||
onSessionClick,
|
||||
}: ActiveSessionsPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusColor = (status?: string, itemType?: string, item?: SessionItem): string => {
|
||||
if (itemType === 'mySession' && item) {
|
||||
const signedCount = item.signedCount ?? 0;
|
||||
const totalCount = item.participantCount ?? 0;
|
||||
|
||||
if (signedCount === totalCount && totalCount > 0) {
|
||||
return 'green'; // All signed
|
||||
}
|
||||
if (signedCount > 0) {
|
||||
return 'yellow'; // Partial
|
||||
}
|
||||
return 'blue'; // None signed
|
||||
}
|
||||
switch (status) {
|
||||
case 'VIEWED':
|
||||
return 'blue';
|
||||
case 'NOTIFIED':
|
||||
case 'PENDING':
|
||||
return 'orange';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (item: SessionItem): string => {
|
||||
if (item.itemType === 'mySession') {
|
||||
const signedCount = item.signedCount ?? 0;
|
||||
const totalCount = item.participantCount ?? 0;
|
||||
if (signedCount === totalCount && totalCount > 0) {
|
||||
return t('certSign.readyToFinalize', 'Ready to finalize');
|
||||
}
|
||||
// Show progress for all cases (including 0/X)
|
||||
if (totalCount > 0) {
|
||||
return t('certSign.signatureProgress', '{{signedCount}}/{{totalCount}} signatures', { signedCount, totalCount });
|
||||
}
|
||||
return t('certSign.awaitingSignatures', 'Awaiting signatures');
|
||||
}
|
||||
|
||||
// For sign requests
|
||||
switch (item.myStatus) {
|
||||
case 'VIEWED':
|
||||
return t('certSign.viewed', 'Viewed');
|
||||
case 'NOTIFIED':
|
||||
return t('certSign.notified', 'Pending');
|
||||
case 'PENDING':
|
||||
return t('certSign.pending', 'Pending');
|
||||
default:
|
||||
return item.myStatus || 'PENDING';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="quick-access-popout__panel">
|
||||
{loading ? (
|
||||
<Center p="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="quick-access-popout__section">
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{t('quickAccess.noActiveSessions', 'No pending sign requests or active sessions')}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={`${session.itemType}-${session.sessionId}`}
|
||||
className="quick-access-popout__sign-request-row"
|
||||
onClick={() => onSessionClick(session)}
|
||||
>
|
||||
<div className="quick-access-popout__sign-request-info">
|
||||
<div className="quick-access-popout__row-title">{session.documentName}</div>
|
||||
<div className="quick-access-popout__row-subtitle">
|
||||
{session.itemType === 'signRequest' ? (
|
||||
<>
|
||||
From: {session.ownerUsername}
|
||||
{session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Created: {new Date(session.createdAt).toLocaleDateString()}
|
||||
{session.signedCount !== undefined &&
|
||||
session.participantCount !== undefined &&
|
||||
` • ${session.signedCount}/${session.participantCount} signed`}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quick-access-popout__sign-request-badge">
|
||||
<Badge size="sm" color={getStatusColor(session.myStatus, session.itemType, session)}>
|
||||
{getStatusLabel(session)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveSessionsPanel;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader, Center, Text, Badge } from '@mantine/core';
|
||||
|
||||
interface SessionItem {
|
||||
itemType: 'signRequest' | 'mySession';
|
||||
sessionId: string;
|
||||
documentName: string;
|
||||
createdAt: string;
|
||||
myStatus?: string;
|
||||
ownerUsername?: string;
|
||||
ownerEmail?: string;
|
||||
dueDate?: string;
|
||||
finalized?: boolean;
|
||||
signedCount?: number;
|
||||
participantCount?: number;
|
||||
}
|
||||
|
||||
interface CompletedSessionsPanelProps {
|
||||
sessions: SessionItem[];
|
||||
loading: boolean;
|
||||
onSessionClick: (session: SessionItem) => void;
|
||||
}
|
||||
|
||||
const CompletedSessionsPanel = ({
|
||||
sessions,
|
||||
loading,
|
||||
onSessionClick,
|
||||
}: CompletedSessionsPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusColor = (status?: string, itemType?: string): string => {
|
||||
if (itemType === 'mySession') return 'green';
|
||||
switch (status) {
|
||||
case 'SIGNED':
|
||||
return 'green';
|
||||
case 'DECLINED':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (item: SessionItem): string => {
|
||||
if (item.itemType === 'mySession') {
|
||||
return t('certSign.finalized', 'Finalized');
|
||||
}
|
||||
|
||||
// For sign requests
|
||||
switch (item.myStatus) {
|
||||
case 'SIGNED':
|
||||
return t('certSign.signed', 'Signed');
|
||||
case 'DECLINED':
|
||||
return t('certSign.declined', 'Declined');
|
||||
default:
|
||||
return item.myStatus || 'COMPLETED';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="quick-access-popout__panel">
|
||||
{loading ? (
|
||||
<Center p="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="quick-access-popout__section">
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{t('quickAccess.noCompletedSessions', 'No completed sessions')}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={`${session.itemType}-${session.sessionId}`}
|
||||
className="quick-access-popout__sign-request-row"
|
||||
onClick={() => onSessionClick(session)}
|
||||
>
|
||||
<div className="quick-access-popout__sign-request-info">
|
||||
<div className="quick-access-popout__row-title">{session.documentName}</div>
|
||||
<div className="quick-access-popout__row-subtitle">
|
||||
{session.itemType === 'signRequest' ? (
|
||||
<>
|
||||
From: {session.ownerUsername}
|
||||
{session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Created: {new Date(session.createdAt).toLocaleDateString()}
|
||||
{session.signedCount !== undefined &&
|
||||
session.participantCount !== undefined &&
|
||||
` • ${session.signedCount}/${session.participantCount} signed`}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quick-access-popout__sign-request-badge">
|
||||
<Badge size="sm" color={getStatusColor(session.myStatus, session.itemType)}>
|
||||
{getStatusLabel(session)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompletedSessionsPanel;
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState } from 'react';
|
||||
import { Stack, Text, Group, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { SelectDocumentStep } from '@app/components/shared/signing/steps/SelectDocumentStep';
|
||||
import { SelectParticipantsStep } from '@app/components/shared/signing/steps/SelectParticipantsStep';
|
||||
import { ConfigureSignatureDefaultsStep } from '@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep';
|
||||
import { ReviewSessionStep } from '@app/components/shared/signing/steps/ReviewSessionStep';
|
||||
import { useGroupSigningTips } from '@app/components/tooltips/useGroupSigningTips';
|
||||
import { useSignatureSettingsTips } from '@app/components/tooltips/useSignatureSettingsTips';
|
||||
import type { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
|
||||
import type { FileState } from '@app/types/file';
|
||||
|
||||
interface CreateSessionFlowProps {
|
||||
selectedFiles: FileState[];
|
||||
selectedUserIds: number[];
|
||||
onSelectedUserIdsChange: (userIds: number[]) => void;
|
||||
dueDate: string;
|
||||
onDueDateChange: (date: string) => void;
|
||||
creating: boolean;
|
||||
onSubmit: (signatureSettings: SignatureSettings) => void;
|
||||
}
|
||||
|
||||
interface StepWrapperProps {
|
||||
number: number;
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
isCompleted: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StepWrapper: React.FC<StepWrapperProps> = ({
|
||||
number,
|
||||
title,
|
||||
isActive,
|
||||
isCompleted,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: isActive
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
backgroundColor: isActive
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
: isCompleted
|
||||
? 'var(--mantine-color-gray-0)'
|
||||
: 'transparent',
|
||||
opacity: !isActive && !isCompleted ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb={isActive ? 'md' : 0}>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isCompleted
|
||||
? 'var(--mantine-color-green-6)'
|
||||
: isActive
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'var(--mantine-color-gray-4)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{isCompleted ? <CheckIcon sx={{ fontSize: 18 }} /> : number}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('groupSigning.steps.stepLabel', 'Step {{number}}', { number })}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t('groupSigning.steps.current', 'Current')}
|
||||
</Badge>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<Badge color="green" variant="light">
|
||||
{t('groupSigning.steps.completed', 'Completed')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isActive && children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
|
||||
selectedFiles,
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
creating,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
// Signature settings state
|
||||
const [signatureSettings, setSignatureSettings] = useState<SignatureSettings>({
|
||||
showSignature: false,
|
||||
pageNumber: 1,
|
||||
reason: '',
|
||||
location: '',
|
||||
showLogo: false,
|
||||
includeSummaryPage: false,
|
||||
});
|
||||
|
||||
const groupSigningTips = useGroupSigningTips();
|
||||
const signatureSettingsTips = useSignatureSettingsTips();
|
||||
|
||||
const hasValidFile = selectedFiles.length === 1;
|
||||
const selectedFile = hasValidFile ? selectedFiles[0] : null;
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: t('groupSigning.steps.selectDocument.title', 'Select Document'),
|
||||
tooltip: groupSigningTips,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: t('groupSigning.steps.selectParticipants.title', 'Choose Participants'),
|
||||
tooltip: null,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: t('groupSigning.steps.configureDefaults.title', 'Configure Signature Settings'),
|
||||
tooltip: signatureSettingsTips,
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: t('groupSigning.steps.review.titleShort', 'Review & Send'),
|
||||
tooltip: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="quick-access-popout__panel">
|
||||
<Stack gap="md">
|
||||
{/* Step 1: Select Document */}
|
||||
<StepWrapper
|
||||
number={1}
|
||||
title={steps[0].title}
|
||||
isActive={currentStep === 1}
|
||||
isCompleted={currentStep > 1}
|
||||
>
|
||||
{hasValidFile ? (
|
||||
<SelectDocumentStep
|
||||
selectedFiles={selectedFiles}
|
||||
onNext={() => setCurrentStep(2)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
'groupSigning.steps.selectDocument.noFile',
|
||||
'Please select a single PDF file from your active files to create a signing session.'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</StepWrapper>
|
||||
|
||||
{/* Step 2: Select Participants */}
|
||||
<StepWrapper
|
||||
number={2}
|
||||
title={steps[1].title}
|
||||
isActive={currentStep === 2}
|
||||
isCompleted={currentStep > 2}
|
||||
>
|
||||
<SelectParticipantsStep
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectedUserIdsChange={onSelectedUserIdsChange}
|
||||
onBack={() => setCurrentStep(1)}
|
||||
onNext={() => setCurrentStep(3)}
|
||||
disabled={creating}
|
||||
/>
|
||||
</StepWrapper>
|
||||
|
||||
{/* Step 3: Configure Signature Defaults */}
|
||||
<StepWrapper
|
||||
number={3}
|
||||
title={steps[2].title}
|
||||
isActive={currentStep === 3}
|
||||
isCompleted={currentStep > 3}
|
||||
>
|
||||
<ConfigureSignatureDefaultsStep
|
||||
settings={signatureSettings}
|
||||
onSettingsChange={setSignatureSettings}
|
||||
onBack={() => setCurrentStep(2)}
|
||||
onNext={() => setCurrentStep(4)}
|
||||
disabled={creating}
|
||||
/>
|
||||
</StepWrapper>
|
||||
|
||||
{/* Step 4: Review & Send */}
|
||||
<StepWrapper
|
||||
number={4}
|
||||
title={steps[3].title}
|
||||
isActive={currentStep === 4}
|
||||
isCompleted={false}
|
||||
>
|
||||
{selectedFile && (
|
||||
<ReviewSessionStep
|
||||
selectedFile={selectedFile}
|
||||
participantCount={selectedUserIds.length}
|
||||
signatureSettings={signatureSettings}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={onDueDateChange}
|
||||
onBack={() => setCurrentStep(3)}
|
||||
onSubmit={() => onSubmit(signatureSettings)}
|
||||
disabled={creating}
|
||||
/>
|
||||
)}
|
||||
</StepWrapper>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, Switch } from '@mantine/core';
|
||||
import UserSelector from '@app/components/shared/UserSelector';
|
||||
import type { FileState } from '@app/types/file';
|
||||
|
||||
interface CreateSessionPanelProps {
|
||||
selectedFiles: FileState[];
|
||||
selectedUserIds: number[];
|
||||
onSelectedUserIdsChange: (userIds: number[]) => void;
|
||||
dueDate: string;
|
||||
onDueDateChange: (date: string) => void;
|
||||
creating: boolean;
|
||||
includeSummaryPage: boolean;
|
||||
onIncludeSummaryPageChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const CreateSessionPanel = ({
|
||||
selectedFiles,
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
creating,
|
||||
includeSummaryPage,
|
||||
onIncludeSummaryPageChange,
|
||||
}: CreateSessionPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasValidFile = selectedFiles.length === 1;
|
||||
|
||||
return (
|
||||
<div className="quick-access-popout__panel">
|
||||
{!hasValidFile ? (
|
||||
<div className="quick-access-popout__section">
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{t('quickAccess.selectSingleFileToRequest', 'Select a single PDF file to request signatures')}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">{t('quickAccess.selectedFile', 'Selected file')}</div>
|
||||
<div className="quick-access-popout__row-title">
|
||||
{selectedFiles[0]?.name || t('quickAccess.noFile', 'No file selected')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">{t('quickAccess.selectUsers', 'Select users to sign')}</div>
|
||||
<UserSelector
|
||||
value={selectedUserIds}
|
||||
onChange={onSelectedUserIdsChange}
|
||||
size="xs"
|
||||
placeholder={t('quickAccess.selectUsersPlaceholder', 'Choose participants...')}
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">{t('quickAccess.dueDate', 'Due date (optional)')}</div>
|
||||
<input
|
||||
type="date"
|
||||
className="quick-access-popout__input"
|
||||
value={dueDate}
|
||||
onChange={(e) => onDueDateChange(e.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="quick-access-popout__section">
|
||||
<Switch
|
||||
label={t('certSign.collab.sessionCreation.includeSummaryPage', 'Include Signature Summary Page')}
|
||||
description={t('certSign.collab.sessionCreation.includeSummaryPageHelp', 'Add a summary page at the end with all signature details')}
|
||||
checked={includeSummaryPage}
|
||||
onChange={(e) => onIncludeSummaryPageChange(e.currentTarget.checked)}
|
||||
disabled={creating}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateSessionPanel;
|
||||
@@ -0,0 +1,845 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer } from '@mantine/core';
|
||||
import { useIsPhone } from '@app/hooks/useIsMobile';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import ActiveSessionsPanel from '@app/components/shared/signing/ActiveSessionsPanel';
|
||||
import CompletedSessionsPanel from '@app/components/shared/signing/CompletedSessionsPanel';
|
||||
import CreateSessionPanel from '@app/components/shared/signing/CreateSessionPanel';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { SignRequestSummary, SignRequestDetail, SessionSummary, SessionDetail } from '@app/types/signingSession';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useFileSelection } from '@app/contexts/file/fileHooks';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import SignRequestWorkbenchView from '@app/components/tools/certSign/SignRequestWorkbenchView';
|
||||
import SessionDetailWorkbenchView from '@app/components/tools/certSign/SessionDetailWorkbenchView';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
export const SIGN_REQUEST_WORKBENCH_TYPE = 'custom:signRequestWorkbench' as const;
|
||||
export const SESSION_DETAIL_WORKBENCH_TYPE = 'custom:sessionDetailWorkbench' as const;
|
||||
|
||||
type SessionItem = (SignRequestSummary | SessionSummary) & {
|
||||
itemType: 'signRequest' | 'mySession';
|
||||
};
|
||||
|
||||
function sortSessions(sessions: SessionItem[], tab: 'active' | 'completed'): SessionItem[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
if (tab === 'active') {
|
||||
const aDue = (a as SignRequestSummary).dueDate;
|
||||
const bDue = (b as SignRequestSummary).dueDate;
|
||||
if (aDue && bDue) return new Date(aDue).getTime() - new Date(bDue).getTime();
|
||||
if (aDue) return -1;
|
||||
if (bDue) return 1;
|
||||
}
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
interface SignPopoutProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
buttonRef: React.RefObject<HTMLDivElement | null>;
|
||||
isRTL: boolean;
|
||||
groupSigningEnabled: boolean;
|
||||
}
|
||||
|
||||
const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: SignPopoutProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isPhone = useIsPhone();
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [popoverPosition, setPopoverPosition] = useState({ top: 160, left: 84 });
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
|
||||
const [showCreatePanel, setShowCreatePanel] = useState(false);
|
||||
|
||||
// Search / filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleTabChange = (tab: 'active' | 'completed') => {
|
||||
setActiveTab(tab);
|
||||
setSearchQuery('');
|
||||
setActiveFilters(new Set());
|
||||
};
|
||||
|
||||
const toggleFilter = (key: string) => {
|
||||
setActiveFilters(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Data state
|
||||
const [signRequests, setSignRequests] = useState<SignRequestSummary[]>([]);
|
||||
const [mySessions, setMySessions] = useState<SessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Create form state
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [includeSummaryPage, setIncludeSummaryPage] = useState(false);
|
||||
|
||||
// Hooks
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
handleToolSelect,
|
||||
} = useToolWorkflow();
|
||||
|
||||
// Workbench IDs
|
||||
const SIGN_REQUEST_WORKBENCH_ID = 'signRequestWorkbench';
|
||||
const SESSION_DETAIL_WORKBENCH_ID = 'sessionDetailWorkbench';
|
||||
|
||||
// Register workbenches when group signing is enabled.
|
||||
// No cleanup on unmount — registration must persist when this component unmounts
|
||||
// on mobile (QuickAccessBar is desktop-only). Re-registering on remount is idempotent.
|
||||
useEffect(() => {
|
||||
if (!groupSigningEnabled) return;
|
||||
|
||||
registerCustomWorkbenchView({
|
||||
id: SIGN_REQUEST_WORKBENCH_ID,
|
||||
workbenchId: SIGN_REQUEST_WORKBENCH_TYPE,
|
||||
label: t('certSign.collab.signRequest.workbenchTitle', 'Sign Request'),
|
||||
component: SignRequestWorkbenchView,
|
||||
hideTopControls: true,
|
||||
hideToolPanel: true,
|
||||
});
|
||||
|
||||
registerCustomWorkbenchView({
|
||||
id: SESSION_DETAIL_WORKBENCH_ID,
|
||||
workbenchId: SESSION_DETAIL_WORKBENCH_TYPE,
|
||||
label: t('certSign.collab.sessionDetail.workbenchTitle', 'Session Management'),
|
||||
component: SessionDetailWorkbenchView,
|
||||
hideTopControls: true,
|
||||
hideToolPanel: true,
|
||||
});
|
||||
}, [groupSigningEnabled]);
|
||||
|
||||
// Unregister workbenches only when the feature is explicitly disabled
|
||||
useEffect(() => {
|
||||
if (groupSigningEnabled) return;
|
||||
unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID);
|
||||
unregisterCustomWorkbenchView(SESSION_DETAIL_WORKBENCH_ID);
|
||||
}, [groupSigningEnabled, unregisterCustomWorkbenchView]);
|
||||
|
||||
// Clear sign request workbench data when the user navigates away from it
|
||||
useEffect(() => {
|
||||
if (currentView !== SIGN_REQUEST_WORKBENCH_TYPE) {
|
||||
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
|
||||
}
|
||||
}, [currentView]);
|
||||
|
||||
// Clear session detail workbench data when the user navigates away from it
|
||||
useEffect(() => {
|
||||
if (currentView !== SESSION_DETAIL_WORKBENCH_TYPE) {
|
||||
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
|
||||
}
|
||||
}, [currentView]);
|
||||
|
||||
// Position popover (desktop/tablet only — phone uses Drawer)
|
||||
useEffect(() => {
|
||||
if (!isOpen || isPhone) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const anchor = buttonRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const left = isRTL ? Math.max(16, rect.left - 360) : rect.right + 12;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Start at button position with small offset
|
||||
let top = rect.top - 24;
|
||||
|
||||
// Ensure minimum top margin
|
||||
top = Math.max(24, top);
|
||||
|
||||
// Calculate available height from top position to bottom of viewport
|
||||
const availableHeight = viewportHeight - top - 24; // 24px bottom margin
|
||||
|
||||
setPopoverPosition({ top, left });
|
||||
setMaxHeight(availableHeight);
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, { capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, { capture: true });
|
||||
};
|
||||
}, [isOpen, isRTL, buttonRef]);
|
||||
|
||||
// Handle outside clicks (desktop/tablet only — Drawer handles its own backdrop on phone)
|
||||
useEffect(() => {
|
||||
if (!isOpen || isPhone) return;
|
||||
|
||||
const handleOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (popoverRef.current?.contains(target)) return;
|
||||
if (buttonRef.current?.contains(target)) return;
|
||||
|
||||
const mantineDropdown = (target as Element).closest?.(
|
||||
'.mantine-Combobox-dropdown, .mantine-Popover-dropdown'
|
||||
);
|
||||
if (mantineDropdown) return;
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose, buttonRef]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [requestsResponse, sessionsResponse] = await Promise.all([
|
||||
apiClient.get<SignRequestSummary[]>('/api/v1/security/cert-sign/sign-requests'),
|
||||
apiClient.get<SessionSummary[]>('/api/v1/security/cert-sign/sessions'),
|
||||
]);
|
||||
setSignRequests(requestsResponse.data);
|
||||
setMySessions(sessionsResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch signing data:', error instanceof Error ? error.message : error);
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.fetchFailed', 'Failed to load signing data'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Fetch data when opened (only needed for group signing sessions)
|
||||
useEffect(() => {
|
||||
if (isOpen && groupSigningEnabled) {
|
||||
fetchData();
|
||||
}
|
||||
}, [isOpen, groupSigningEnabled, fetchData]);
|
||||
|
||||
// Auto-refresh Active tab every 15 seconds to show updated signature status
|
||||
useEffect(() => {
|
||||
if (isOpen && groupSigningEnabled && activeTab === 'active' && !showCreatePanel) {
|
||||
const interval = setInterval(() => {
|
||||
fetchData();
|
||||
}, 15000); // Refresh every 15 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen, activeTab, showCreatePanel, fetchData]);
|
||||
|
||||
// Combine and filter sessions
|
||||
const activeSessions: SessionItem[] = [
|
||||
// Sign requests where user hasn't signed or declined yet
|
||||
...signRequests
|
||||
.filter(req => req.myStatus !== 'SIGNED' && req.myStatus !== 'DECLINED')
|
||||
.map(req => ({ ...req, itemType: 'signRequest' as const })),
|
||||
// Sessions user created that aren't finalized yet
|
||||
...mySessions
|
||||
.filter(s => !s.finalized)
|
||||
.map(s => ({ ...s, itemType: 'mySession' as const })),
|
||||
];
|
||||
|
||||
const completedSessions: SessionItem[] = [
|
||||
// Sign requests where user has signed or declined
|
||||
...signRequests
|
||||
.filter(req => req.myStatus === 'SIGNED' || req.myStatus === 'DECLINED')
|
||||
.map(req => ({ ...req, itemType: 'signRequest' as const })),
|
||||
// Sessions user created that have been finalized
|
||||
...mySessions
|
||||
.filter(s => s.finalized)
|
||||
.map(s => ({ ...s, itemType: 'mySession' as const })),
|
||||
];
|
||||
|
||||
// Filter options vary by tab
|
||||
const filterOptions = activeTab === 'active'
|
||||
? [
|
||||
{ key: 'mine', label: t('quickAccess.filterMine', 'Mine') },
|
||||
{ key: 'overdue', label: t('quickAccess.filterOverdue', 'Overdue') },
|
||||
]
|
||||
: [
|
||||
{ key: 'mine', label: t('quickAccess.filterMine', 'Mine') },
|
||||
{ key: 'signed', label: t('quickAccess.filterSigned', 'Signed') },
|
||||
{ key: 'declined',label: t('quickAccess.filterDeclined', 'Declined') },
|
||||
];
|
||||
|
||||
const applyFiltersAndSearch = (sessions: SessionItem[]): SessionItem[] => {
|
||||
let result = sessions;
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(s => s.documentName.toLowerCase().includes(q));
|
||||
}
|
||||
const now = new Date();
|
||||
if (activeFilters.has('mine')) result = result.filter(s => s.itemType === 'mySession');
|
||||
if (activeFilters.has('overdue')) result = result.filter(s => (s as SignRequestSummary).dueDate && new Date((s as SignRequestSummary).dueDate) < now);
|
||||
if (activeFilters.has('signed')) result = result.filter(s => (s as SignRequestSummary).myStatus === 'SIGNED');
|
||||
if (activeFilters.has('declined')) result = result.filter(s => (s as SignRequestSummary).myStatus === 'DECLINED');
|
||||
return result;
|
||||
};
|
||||
|
||||
const displayedActiveSessions = applyFiltersAndSearch(sortSessions(activeSessions, 'active'));
|
||||
const displayedCompletedSessions = applyFiltersAndSearch(sortSessions(completedSessions, 'completed'));
|
||||
|
||||
// Create session handler
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (selectedUserIds.length === 0 || selectedFiles.length !== 1) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const selectedFile = selectedFiles[0];
|
||||
const stirlingFile = await fileStorage.getStirlingFile(selectedFile.fileId);
|
||||
if (!stirlingFile) throw new Error('File not found');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', stirlingFile, selectedFile.name);
|
||||
formData.append('workflowType', 'SIGNING');
|
||||
formData.append('documentName', selectedFile.name);
|
||||
selectedUserIds.forEach((userId, index) => {
|
||||
formData.append(`participantUserIds[${index}]`, userId.toString());
|
||||
});
|
||||
if (dueDate) formData.append('dueDate', dueDate);
|
||||
formData.append('notifyOnCreate', 'true');
|
||||
|
||||
// Send includeSummaryPage setting as workflowMetadata if enabled
|
||||
if (includeSummaryPage) {
|
||||
const workflowMetadata = JSON.stringify({
|
||||
includeSummaryPage: true,
|
||||
});
|
||||
formData.append('workflowMetadata', workflowMetadata);
|
||||
}
|
||||
|
||||
await apiClient.post('/api/v1/security/cert-sign/sessions', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('signSession.created', 'Signing request sent'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
|
||||
setSelectedUserIds([]);
|
||||
setDueDate('');
|
||||
setIncludeSummaryPage(false);
|
||||
setShowCreatePanel(false);
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error instanceof Error ? error.message : error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('signSession.createFailed', 'Failed to create signing request'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]);
|
||||
|
||||
// Handle clicking a sign request
|
||||
const handleSignRequestClick = useCallback(async (request: SignRequestSummary) => {
|
||||
onClose();
|
||||
try {
|
||||
const [detailResponse, pdfResponse] = await Promise.all([
|
||||
apiClient.get<SignRequestDetail>(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}`),
|
||||
apiClient.get(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
]);
|
||||
|
||||
const pdfFile = new File([pdfResponse.data], detailResponse.data.documentName, {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const canSign =
|
||||
detailResponse.data.myStatus === 'PENDING' ||
|
||||
detailResponse.data.myStatus === 'NOTIFIED' ||
|
||||
detailResponse.data.myStatus === 'VIEWED';
|
||||
|
||||
setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, {
|
||||
signRequest: detailResponse.data,
|
||||
pdfFile,
|
||||
onSign: (certData: FormData) => handleSign(request.sessionId, certData),
|
||||
onDecline: () => handleDecline(request.sessionId),
|
||||
onBack: () => {
|
||||
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
},
|
||||
canSign,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
navigationActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load sign request:', error instanceof Error ? error.message : error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('signRequest.fetchFailed', 'Failed to load sign request'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
}
|
||||
}, [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t]);
|
||||
|
||||
// Handle clicking a session
|
||||
const handleSessionClick = useCallback(async (session: SessionSummary) => {
|
||||
onClose();
|
||||
try {
|
||||
// First fetch session detail
|
||||
const detailResponse = await apiClient.get<SessionDetail>(
|
||||
`/api/v1/security/cert-sign/sessions/${session.sessionId}`
|
||||
);
|
||||
|
||||
// Determine which endpoint to use based on session state
|
||||
let pdfFile: File | null = null;
|
||||
|
||||
if (detailResponse.data.finalized) {
|
||||
// Finalized sessions have signed PDF available
|
||||
try {
|
||||
const pdfResponse = await apiClient.get(
|
||||
`/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
pdfFile = new File([pdfResponse.data], session.documentName, { type: 'application/pdf' });
|
||||
} catch (pdfError: any) {
|
||||
if (pdfError?.response?.status === 404) {
|
||||
// Finalized but signed PDF not available - backend issue
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('certSign.sessions.pdfNotReady', 'PDF Not Ready'),
|
||||
body: t('certSign.sessions.pdfNotReadyDesc', 'The signed PDF is being generated. Please try again in a moment.'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw pdfError;
|
||||
}
|
||||
} else {
|
||||
// For non-finalized sessions, get original PDF (always available)
|
||||
try {
|
||||
const pdfResponse = await apiClient.get(
|
||||
`/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
pdfFile = new File([pdfResponse.data], session.documentName, { type: 'application/pdf' });
|
||||
} catch (_error) {
|
||||
// Fallback if PDF not available
|
||||
pdfFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, {
|
||||
session: detailResponse.data,
|
||||
pdfFile,
|
||||
onFinalize: () => handleFinalize(session.sessionId, session.documentName),
|
||||
onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName),
|
||||
onAddParticipants: (userIds: number[], defaultReason?: string) =>
|
||||
handleAddParticipants(session.sessionId, userIds, defaultReason),
|
||||
onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId),
|
||||
onDelete: () => handleDeleteSession(session.sessionId),
|
||||
onBack: () => {
|
||||
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
},
|
||||
onRefresh: () => handleRefreshSession(session.sessionId),
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
navigationActions.setWorkbench(SESSION_DETAIL_WORKBENCH_TYPE);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load session:', error instanceof Error ? error.message : error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.sessions.fetchFailed', 'Failed to load session details'),
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
}
|
||||
}, [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t]);
|
||||
|
||||
// Action handlers
|
||||
const handleSign = async (sessionId: string, certificateData: FormData) => {
|
||||
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('signRequest.signed', 'Document signed successfully'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleDecline = async (sessionId: string) => {
|
||||
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('signRequest.declined', 'Sign request declined'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleFinalize = async (sessionId: string, documentName: string) => {
|
||||
const response = await apiClient.post(
|
||||
`/api/v1/security/cert-sign/sessions/${sessionId}/finalize`,
|
||||
null,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`;
|
||||
const signedFile = new File([response.data], filename, { type: 'application/pdf' });
|
||||
await fileActions.addFiles([signedFile]);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('certSign.sessions.finalized', 'Session finalized'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleLoadSignedPdf = async (sessionId: string, documentName: string) => {
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`;
|
||||
const signedFile = new File([response.data], filename, { type: 'application/pdf' });
|
||||
await fileActions.addFiles([signedFile]);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('certSign.sessions.loaded', 'Signed PDF loaded'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
};
|
||||
|
||||
const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => {
|
||||
const requests = userIds.map(userId => ({
|
||||
userId,
|
||||
defaultReason: defaultReason || undefined,
|
||||
sendNotification: true,
|
||||
}));
|
||||
await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/participants`, requests);
|
||||
await handleRefreshSession(sessionId);
|
||||
};
|
||||
|
||||
const handleRemoveParticipant = async (sessionId: string, participantId: number) => {
|
||||
await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`);
|
||||
await handleRefreshSession(sessionId);
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (sessionId: string) => {
|
||||
await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}`);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('certSign.sessions.deleted', 'Session deleted'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleRefreshSession = async (sessionId: string) => {
|
||||
const response = await apiClient.get<SessionDetail>(
|
||||
`/api/v1/security/cert-sign/sessions/${sessionId}`
|
||||
);
|
||||
// Update workbench data, preserving PDF and callbacks
|
||||
setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, (prevData: any) => ({
|
||||
...prevData,
|
||||
session: response.data,
|
||||
}));
|
||||
};
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
// Shared card content — rendered inside either the portal (desktop/tablet) or Drawer (phone)
|
||||
const popoutCard = (
|
||||
<div className="quick-access-popout__card" style={{ maxHeight: !isPhone && maxHeight ? `${maxHeight}px` : undefined }}>
|
||||
{/* Header */}
|
||||
<div className="quick-access-popout__header">
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-access-popout__back ${showCreatePanel ? 'is-visible' : ''}`}
|
||||
onClick={() => setShowCreatePanel(false)}
|
||||
aria-label={t('quickAccess.back', 'Back')}
|
||||
>
|
||||
<LocalIcon icon="arrow-back-rounded" width="1rem" height="1rem" />
|
||||
</button>
|
||||
<div className="quick-access-popout__title">
|
||||
{showCreatePanel
|
||||
? t('quickAccess.createSession', 'Create Signing Request')
|
||||
: groupSigningEnabled && activeTab === 'active'
|
||||
? t('quickAccess.activeSessions', 'Active Sessions')
|
||||
: groupSigningEnabled
|
||||
? t('quickAccess.completedSessions', 'Completed Sessions')
|
||||
: t('quickAccess.sign', 'Sign')}
|
||||
</div>
|
||||
<div className="quick-access-popout__header-actions">
|
||||
{!showCreatePanel && (
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__header-action"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
aria-label={t('quickAccess.refresh', 'Refresh')}
|
||||
style={{ opacity: loading ? 0.5 : 0.7 }}
|
||||
>
|
||||
<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__header-action"
|
||||
onClick={onClose}
|
||||
aria-label={t('close', 'Close')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick sign tools */}
|
||||
{!showCreatePanel && (
|
||||
<div className="quick-access-popout__quick-sign">
|
||||
<div className="quick-access-popout__section-label">
|
||||
{t('quickAccess.signYourself', 'Sign Yourself')}
|
||||
</div>
|
||||
<div className="quick-access-popout__quick-sign-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__quick-sign-btn"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
handleToolSelect('sign');
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="signature-rounded" width="1rem" height="1rem" />
|
||||
{t('quickAccess.wetSign', 'Add Signature')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__quick-sign-btn"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
handleToolSelect('certSign');
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="workspace-premium-rounded" width="1rem" height="1rem" />
|
||||
{t('quickAccess.certSign', 'Certificate Sign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature Requests section label + Tab Navigation */}
|
||||
{!showCreatePanel && groupSigningEnabled && (
|
||||
<>
|
||||
<div className="quick-access-popout__section-label quick-access-popout__section-label--padded quick-access-popout__section-label--row">
|
||||
<span>{t('quickAccess.signatureRequests', 'Signature Requests')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__section-action"
|
||||
onClick={() => setShowCreatePanel(true)}
|
||||
aria-label={t('quickAccess.newRequest', 'New request')}
|
||||
title={t('quickAccess.newRequest', 'New request')}
|
||||
>
|
||||
<LocalIcon icon="add-rounded" width="1rem" height="1rem" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="quick-access-popout__tab-nav">
|
||||
<button
|
||||
className={`quick-access-popout__tab-button ${activeTab === 'active' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('active')}
|
||||
>
|
||||
{t('quickAccess.activeTab', 'Active')}
|
||||
</button>
|
||||
<button
|
||||
className={`quick-access-popout__tab-button ${activeTab === 'completed' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('completed')}
|
||||
>
|
||||
{t('quickAccess.completedTab', 'Completed')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search + filter bar */}
|
||||
{!showCreatePanel && groupSigningEnabled && (
|
||||
<div className="quick-access-popout__search-filter">
|
||||
<input
|
||||
type="search"
|
||||
className="quick-access-popout__search"
|
||||
placeholder={t('quickAccess.searchDocuments', 'Search documents…')}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="quick-access-popout__filter-chips">
|
||||
{filterOptions.map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={`quick-access-popout__filter-chip ${activeFilters.has(f.key) ? 'is-active' : ''}`}
|
||||
onClick={() => toggleFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{groupSigningEnabled && (
|
||||
<div className="quick-access-popout__body">
|
||||
{showCreatePanel ? (
|
||||
<CreateSessionPanel
|
||||
selectedFiles={selectedFiles}
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectedUserIdsChange={setSelectedUserIds}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={setDueDate}
|
||||
creating={creating}
|
||||
includeSummaryPage={includeSummaryPage}
|
||||
onIncludeSummaryPageChange={setIncludeSummaryPage}
|
||||
/>
|
||||
) : activeTab === 'active' ? (
|
||||
<ActiveSessionsPanel
|
||||
sessions={displayedActiveSessions}
|
||||
loading={loading}
|
||||
onSessionClick={(item) => {
|
||||
if (item.itemType === 'signRequest') {
|
||||
handleSignRequestClick(item as SignRequestSummary);
|
||||
} else {
|
||||
handleSessionClick(item as SessionSummary);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CompletedSessionsPanel
|
||||
sessions={displayedCompletedSessions}
|
||||
loading={loading}
|
||||
onSessionClick={(item) => {
|
||||
if (item.itemType === 'signRequest') {
|
||||
handleSignRequestClick(item as SignRequestSummary);
|
||||
} else {
|
||||
handleSessionClick(item as SessionSummary);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{groupSigningEnabled && showCreatePanel && (
|
||||
<div className="quick-access-popout__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="quick-access-popout__primary"
|
||||
onClick={handleCreateSession}
|
||||
disabled={selectedFiles.length !== 1 || selectedUserIds.length === 0 || creating}
|
||||
>
|
||||
<LocalIcon icon="send-rounded" width="1rem" height="1rem" />
|
||||
{creating
|
||||
? t('quickAccess.sendingRequest', 'Sending...')
|
||||
: t('quickAccess.requestSignatures', 'Request Signatures')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Phone: bottom-sheet Drawer (full height)
|
||||
if (isPhone) {
|
||||
return (
|
||||
<Drawer
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
position="bottom"
|
||||
size="100%"
|
||||
withCloseButton={false}
|
||||
padding={0}
|
||||
className="quick-access-sign-popout"
|
||||
styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column' } }}
|
||||
>
|
||||
{popoutCard}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop / tablet: fixed-position portal
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={`quick-access-popout quick-access-sign-popout ${isOpen ? 'is-open' : ''}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`,
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{popoutCard}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default SignPopout;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Button, Stack, Text, Group, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SignatureSettingsInput, { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
|
||||
|
||||
interface ConfigureSignatureDefaultsStepProps {
|
||||
settings: SignatureSettings;
|
||||
onSettingsChange: (settings: SignatureSettings) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ConfigureSignatureDefaultsStep: React.FC<ConfigureSignatureDefaultsStepProps> = ({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
onBack,
|
||||
onNext,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SignatureSettingsInput
|
||||
value={settings}
|
||||
onChange={onSettingsChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Paper p="sm" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t('groupSigning.steps.configureDefaults.preview', 'Preview')}
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{settings.showSignature
|
||||
? t(
|
||||
'groupSigning.steps.configureDefaults.visible',
|
||||
'Signatures will be visible on page {{page}}',
|
||||
{ page: settings.pageNumber || 1 }
|
||||
)
|
||||
: t(
|
||||
'groupSigning.steps.configureDefaults.invisible',
|
||||
'Signatures will be invisible (metadata only)'
|
||||
)}
|
||||
</Text>
|
||||
{settings.showSignature && settings.reason && (
|
||||
<Text size="xs">
|
||||
<strong>{t('groupSigning.steps.configureDefaults.reasonLabel', 'Reason:')}</strong>{' '}
|
||||
{settings.reason}
|
||||
</Text>
|
||||
)}
|
||||
{settings.showSignature && settings.location && (
|
||||
<Text size="xs">
|
||||
<strong>
|
||||
{t('groupSigning.steps.configureDefaults.locationLabel', 'Location:')}
|
||||
</strong>{' '}
|
||||
{settings.location}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('groupSigning.steps.back', 'Back')}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={disabled} style={{ flex: 1 }}>
|
||||
{t('groupSigning.steps.configureDefaults.continue', 'Continue to Review')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Button, Stack, Text, Group, Divider, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import DrawIcon from '@mui/icons-material/Draw';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
||||
import type { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
|
||||
import type { FileState } from '@app/types/file';
|
||||
|
||||
interface ReviewSessionStepProps {
|
||||
selectedFile: FileState;
|
||||
participantCount: number;
|
||||
signatureSettings: SignatureSettings;
|
||||
dueDate: string;
|
||||
onDueDateChange: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
|
||||
selectedFile,
|
||||
participantCount,
|
||||
signatureSettings,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
onBack,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('groupSigning.steps.review.title', 'Review Session Details')}
|
||||
</Text>
|
||||
|
||||
{/* Document Info */}
|
||||
<div>
|
||||
<Group gap="xs" mb="xs">
|
||||
<PictureAsPdfIcon sx={{ fontSize: 18, color: 'var(--mantine-color-red-6)' }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{t('groupSigning.steps.review.document', 'Document')}
|
||||
</Text>
|
||||
</Group>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{selectedFile.name}</Text>
|
||||
{selectedFile.size && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Participants */}
|
||||
<div>
|
||||
<Group gap="xs" mb="xs">
|
||||
<PeopleIcon sx={{ fontSize: 18 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{t('groupSigning.steps.review.participants', 'Participants')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{t('groupSigning.steps.review.participantCount', {
|
||||
count: participantCount,
|
||||
defaultValue: '{{count}} participant(s) will sign in order',
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Signature Settings */}
|
||||
<div>
|
||||
<Group gap="xs" mb="xs">
|
||||
<DrawIcon sx={{ fontSize: 18 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{t('groupSigning.steps.review.signatureSettings', 'Signature Settings')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
<strong>{t('groupSigning.steps.review.visibility', 'Visibility:')}</strong>{' '}
|
||||
{signatureSettings.showSignature
|
||||
? t('groupSigning.steps.review.visible', 'Visible on page {{page}}', {
|
||||
page: signatureSettings.pageNumber || 1,
|
||||
})
|
||||
: t('groupSigning.steps.review.invisible', 'Invisible (metadata only)')}
|
||||
</Text>
|
||||
{signatureSettings.showSignature && signatureSettings.reason && (
|
||||
<Text size="sm">
|
||||
<strong>{t('groupSigning.steps.review.reason', 'Reason:')}</strong> {signatureSettings.reason}
|
||||
</Text>
|
||||
)}
|
||||
{signatureSettings.showSignature && signatureSettings.location && (
|
||||
<Text size="sm">
|
||||
<strong>{t('groupSigning.steps.review.location', 'Location:')}</strong> {signatureSettings.location}
|
||||
</Text>
|
||||
)}
|
||||
{signatureSettings.showSignature && (
|
||||
<Text size="sm">
|
||||
<strong>{t('groupSigning.steps.review.logo', 'Logo:')}</strong>{' '}
|
||||
{signatureSettings.showLogo
|
||||
? t('groupSigning.steps.review.logoShown', 'Stirling PDF logo shown')
|
||||
: t('groupSigning.steps.review.logoHidden', 'No logo')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<Group gap="xs" mb="xs">
|
||||
<CalendarTodayIcon sx={{ fontSize: 18 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{t('groupSigning.steps.review.dueDate', 'Due Date (Optional)')}
|
||||
</Text>
|
||||
</Group>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => onDueDateChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={t('groupSigning.steps.review.dueDatePlaceholder', 'Select due date...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group gap="sm" mt="md">
|
||||
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('groupSigning.steps.back', 'Back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
leftSection={<SendIcon sx={{ fontSize: 16 }} />}
|
||||
color="green"
|
||||
>
|
||||
{t('groupSigning.steps.review.send', 'Send Signing Requests')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Button, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import type { FileState } from '@app/types/file';
|
||||
|
||||
interface SelectDocumentStepProps {
|
||||
selectedFiles: FileState[];
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({
|
||||
selectedFiles,
|
||||
onNext,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasValidFile = selectedFiles.length === 1;
|
||||
const selectedFile = hasValidFile ? selectedFiles[0] : null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!hasValidFile ? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
'groupSigning.steps.selectDocument.noFile',
|
||||
'Please select a single PDF file from your active files to create a signing session.'
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{t('groupSigning.steps.selectDocument.selectedFile', 'Selected document')}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
>
|
||||
<PictureAsPdfIcon sx={{ fontSize: 32, color: 'var(--mantine-color-red-6)' }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{selectedFile?.name}
|
||||
</Text>
|
||||
{selectedFile?.size && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} fullWidth>
|
||||
{t('groupSigning.steps.selectDocument.continue', 'Continue to Participant Selection')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button, Stack, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import UserSelector from '@app/components/shared/UserSelector';
|
||||
|
||||
interface SelectParticipantsStepProps {
|
||||
selectedUserIds: number[];
|
||||
onSelectedUserIdsChange: (userIds: number[]) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SelectParticipantsStep: React.FC<SelectParticipantsStepProps> = ({
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
onBack,
|
||||
onNext,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasParticipants = selectedUserIds.length > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{t('groupSigning.steps.selectParticipants.label', 'Select participants')}
|
||||
</Text>
|
||||
<UserSelector
|
||||
value={selectedUserIds}
|
||||
onChange={onSelectedUserIdsChange}
|
||||
placeholder={t(
|
||||
'groupSigning.steps.selectParticipants.placeholder',
|
||||
'Choose participants to sign...'
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedUserIds.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('groupSigning.steps.selectParticipants.count', {
|
||||
count: selectedUserIds.length,
|
||||
defaultValue: '{{count}} participant(s) selected',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('groupSigning.steps.back', 'Back')}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!hasParticipants || disabled} style={{ flex: 1 }}>
|
||||
{t('groupSigning.steps.selectParticipants.continue', 'Continue to Signature Settings')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { Stack, Button, Group, ColorPicker, Slider, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
interface DrawSignatureCanvasProps {
|
||||
signature: string | null;
|
||||
onChange: (signature: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
|
||||
signature,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [penColor, setPenColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw existing signature if any
|
||||
if (signature) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
img.src = signature;
|
||||
}
|
||||
}, [signature]);
|
||||
|
||||
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (disabled) return;
|
||||
setIsDrawing(true);
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
};
|
||||
|
||||
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || disabled) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.lineTo(x, y);
|
||||
ctx.strokeStyle = penColor;
|
||||
ctx.lineWidth = penSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
if (!isDrawing) return;
|
||||
setIsDrawing(false);
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Convert canvas to base64 and save
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
onChange(dataUrl);
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('certSign.collab.signRequest.drawSignature', 'Draw your signature below')}
|
||||
</Text>
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={150}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
cursor: disabled ? 'not-allowed' : 'crosshair',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group gap="sm">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="xs" mb={4}>
|
||||
{t('certSign.collab.signRequest.penColor', 'Pen Color')}
|
||||
</Text>
|
||||
<ColorPicker
|
||||
value={penColor}
|
||||
onChange={setPenColor}
|
||||
format="hex"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<Text size="xs" mb={4}>
|
||||
{t('certSign.collab.signRequest.penSize', 'Pen Size: {{size}}px', { size: penSize })}
|
||||
</Text>
|
||||
<Slider
|
||||
value={penSize}
|
||||
onChange={setPenSize}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
marks={[
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 10, label: '10' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<DeleteIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={clearCanvas}
|
||||
disabled={disabled || !signature}
|
||||
fullWidth
|
||||
>
|
||||
{t('certSign.collab.signRequest.clearSignature', 'Clear Signature')}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type SignatureType = 'draw' | 'upload' | 'type';
|
||||
|
||||
interface SignatureTypeSelectorProps {
|
||||
value: SignatureType;
|
||||
onChange: (value: SignatureType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SignatureTypeSelector: React.FC<SignatureTypeSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
value={value}
|
||||
onChange={(val) => onChange(val as SignatureType)}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{
|
||||
value: 'draw',
|
||||
label: t('certSign.collab.signRequest.signatureType.draw', 'Draw'),
|
||||
},
|
||||
{
|
||||
value: 'upload',
|
||||
label: t('certSign.collab.signRequest.signatureType.upload', 'Upload'),
|
||||
},
|
||||
{
|
||||
value: 'type',
|
||||
label: t('certSign.collab.signRequest.signatureType.type', 'Type'),
|
||||
},
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Stack, TextInput, Select, ColorPicker, Slider, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface TypeSignatureTextProps {
|
||||
text: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onFontFamilyChange: (fontFamily: string) => void;
|
||||
onFontSizeChange: (fontSize: number) => void;
|
||||
onColorChange: (color: string) => void;
|
||||
onSignatureChange: (signature: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
|
||||
text,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
color,
|
||||
onTextChange,
|
||||
onFontFamilyChange,
|
||||
onFontSizeChange,
|
||||
onColorChange,
|
||||
onSignatureChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Generate signature image when text/style changes
|
||||
useEffect(() => {
|
||||
if (!text || !canvasRef.current) {
|
||||
onSignatureChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Set font and measure text
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = ctx.measureText(text);
|
||||
const textWidth = metrics.width;
|
||||
const _textHeight = fontSize * 1.2; // Approximate height
|
||||
|
||||
// Center text on canvas
|
||||
ctx.fillStyle = color;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, (canvas.width - textWidth) / 2, canvas.height / 2);
|
||||
|
||||
// Convert to base64
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
onSignatureChange(dataUrl);
|
||||
}, [text, fontFamily, fontSize, color, onSignatureChange]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Times New Roman', label: 'Times New Roman' },
|
||||
{ value: 'Courier New', label: 'Courier New' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
{ value: 'Verdana', label: 'Verdana' },
|
||||
{ value: 'Comic Sans MS', label: 'Comic Sans MS' },
|
||||
{ value: 'Brush Script MT', label: 'Brush Script MT (cursive)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('certSign.collab.signRequest.typeSignature', 'Type your name to create a signature')}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t('certSign.collab.signRequest.signatureText', 'Signature Text')}
|
||||
placeholder={t('certSign.collab.signRequest.signatureTextPlaceholder', 'Enter your name...')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certSign.collab.signRequest.fontFamily', 'Font Family')}
|
||||
value={fontFamily}
|
||||
onChange={(val) => val && onFontFamilyChange(val)}
|
||||
data={fontOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size="sm" mb={4}>
|
||||
{t('certSign.collab.signRequest.fontSize', 'Font Size: {{size}}px', { size: fontSize })}
|
||||
</Text>
|
||||
<Slider
|
||||
value={fontSize}
|
||||
onChange={onFontSizeChange}
|
||||
min={20}
|
||||
max={80}
|
||||
step={2}
|
||||
disabled={disabled}
|
||||
marks={[
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 80, label: '80' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" mb={4}>
|
||||
{t('certSign.collab.signRequest.textColor', 'Text Color')}
|
||||
</Text>
|
||||
<ColorPicker
|
||||
value={color}
|
||||
onChange={onColorChange}
|
||||
format="hex"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{text && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
padding: '16px',
|
||||
backgroundColor: 'white',
|
||||
minHeight: '100px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: fontFamily,
|
||||
fontSize: `${fontSize}px`,
|
||||
color: color,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden canvas for image generation */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={150}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Stack, Button, Text, Image } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
interface UploadSignatureImageProps {
|
||||
signature: string | null;
|
||||
onChange: (signature: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
|
||||
signature,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError(t('certSign.collab.signRequest.invalidFileType', 'Please select an image file'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError(t('certSign.collab.signRequest.fileTooLarge', 'File size must be less than 5MB'));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
onChange(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('certSign.collab.signRequest.uploadSignature', 'Upload your signature image')}
|
||||
</Text>
|
||||
|
||||
{signature ? (
|
||||
<Stack gap="sm">
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
padding: '16px',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '150px',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={signature}
|
||||
alt="Signature"
|
||||
fit="contain"
|
||||
style={{ maxHeight: '150px', maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<DeleteIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={handleClear}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('certSign.collab.signRequest.removeImage', 'Remove Image')}
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<UploadFileIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={handleUploadClick}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('certSign.collab.signRequest.selectFile', 'Select Image File')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="xs" c="red">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Stack, Radio, Divider, TextInput, Text, Group, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import FileUploadButton from '@app/components/shared/FileUploadButton';
|
||||
|
||||
export type CertificateType = 'USER_CERT' | 'SERVER' | 'UPLOAD';
|
||||
export type UploadFormat = 'PKCS12' | 'PFX' | 'PEM' | 'JKS';
|
||||
|
||||
interface CertificateSelectorProps {
|
||||
certType: CertificateType;
|
||||
onCertTypeChange: (certType: CertificateType) => void;
|
||||
uploadFormat: UploadFormat;
|
||||
onUploadFormatChange: (format: UploadFormat) => void;
|
||||
p12File: File | null;
|
||||
onP12FileChange: (file: File | null) => void;
|
||||
privateKeyFile: File | null;
|
||||
onPrivateKeyFileChange: (file: File | null) => void;
|
||||
certFile: File | null;
|
||||
onCertFileChange: (file: File | null) => void;
|
||||
jksFile: File | null;
|
||||
onJksFileChange: (file: File | null) => void;
|
||||
password: string;
|
||||
onPasswordChange: (password: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CertificateSelector: React.FC<CertificateSelectorProps> = ({
|
||||
certType,
|
||||
onCertTypeChange,
|
||||
uploadFormat,
|
||||
onUploadFormatChange,
|
||||
p12File,
|
||||
onP12FileChange,
|
||||
privateKeyFile,
|
||||
onPrivateKeyFileChange,
|
||||
certFile,
|
||||
onCertFileChange,
|
||||
jksFile,
|
||||
onJksFileChange,
|
||||
password,
|
||||
onPasswordChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const isServerPlan = config?.runningProOrHigher ?? false;
|
||||
|
||||
// If managed cert types are not available, reset to UPLOAD
|
||||
useEffect(() => {
|
||||
if (!isServerPlan && (certType === 'USER_CERT' || certType === 'SERVER')) {
|
||||
onCertTypeChange('UPLOAD');
|
||||
}
|
||||
}, [isServerPlan, certType, onCertTypeChange]);
|
||||
|
||||
const handleFormatChange = (fmt: UploadFormat) => {
|
||||
onUploadFormatChange(fmt);
|
||||
onP12FileChange(null);
|
||||
onPrivateKeyFileChange(null);
|
||||
onCertFileChange(null);
|
||||
onJksFileChange(null);
|
||||
onPasswordChange('');
|
||||
};
|
||||
|
||||
const showPassword =
|
||||
((uploadFormat === 'PKCS12' || uploadFormat === 'PFX') && p12File) ||
|
||||
(uploadFormat === 'PEM' && privateKeyFile && certFile) ||
|
||||
(uploadFormat === 'JKS' && jksFile);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Managed certificate options — server plan only */}
|
||||
{isServerPlan && (
|
||||
<Radio.Group
|
||||
value={certType}
|
||||
onChange={(val) => onCertTypeChange(val as CertificateType)}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Radio
|
||||
value="USER_CERT"
|
||||
disabled={disabled}
|
||||
label={
|
||||
<Stack gap={1}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('certSign.collab.signRequest.usePersonalCert', 'Personal Certificate')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signRequest.usePersonalCertDesc', 'Auto-generated for your account')}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
value="SERVER"
|
||||
disabled={disabled}
|
||||
label={
|
||||
<Stack gap={1}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('certSign.collab.signRequest.useServerCert', 'Organization Certificate')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signRequest.useServerCertDesc', 'Shared organization certificate')}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
value="UPLOAD"
|
||||
disabled={disabled}
|
||||
label={
|
||||
<Text size="sm" fw={500}>
|
||||
{t('certSign.collab.signRequest.uploadCert', 'Custom Certificate')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
)}
|
||||
|
||||
{/* Upload section */}
|
||||
{certType === 'UPLOAD' && (
|
||||
<Stack gap="sm">
|
||||
{isServerPlan && (
|
||||
<Divider
|
||||
label={t('certSign.collab.signRequest.uploadCert', 'Custom Certificate')}
|
||||
labelPosition="left"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Format picker */}
|
||||
<Group gap="xs">
|
||||
{(['PKCS12', 'PFX', 'PEM', 'JKS'] as UploadFormat[]).map((fmt) => (
|
||||
<Button
|
||||
key={fmt}
|
||||
size="xs"
|
||||
variant={uploadFormat === fmt ? 'filled' : 'light'}
|
||||
onClick={() => handleFormatChange(fmt)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{fmt}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{/* PKCS12 / PFX */}
|
||||
{(uploadFormat === 'PKCS12' || uploadFormat === 'PFX') && (
|
||||
<FileUploadButton
|
||||
file={p12File ?? undefined}
|
||||
onChange={(file) => onP12FileChange(file || null)}
|
||||
accept=".p12,.pfx"
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
uploadFormat === 'PFX'
|
||||
? t('certSign.choosePfxFile', 'Choose PFX File')
|
||||
: t('certSign.chooseP12File', 'Choose PKCS12 File')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PEM */}
|
||||
{uploadFormat === 'PEM' && (
|
||||
<Stack gap="xs">
|
||||
<FileUploadButton
|
||||
file={privateKeyFile ?? undefined}
|
||||
onChange={(file) => onPrivateKeyFileChange(file || null)}
|
||||
accept=".pem,.der,.key"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.choosePrivateKey', 'Choose Private Key File')}
|
||||
/>
|
||||
{privateKeyFile && (
|
||||
<FileUploadButton
|
||||
file={certFile ?? undefined}
|
||||
onChange={(file) => onCertFileChange(file || null)}
|
||||
accept=".pem,.der,.crt,.cer"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.chooseCertificate', 'Choose Certificate File')}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* JKS */}
|
||||
{uploadFormat === 'JKS' && (
|
||||
<FileUploadButton
|
||||
file={jksFile ?? undefined}
|
||||
onChange={(file) => onJksFileChange(file || null)}
|
||||
accept=".jks,.keystore"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.chooseJksFile', 'Choose JKS File')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
{showPassword && (
|
||||
<TextInput
|
||||
label={t('certSign.collab.signRequest.password', 'Certificate Password')}
|
||||
type="password"
|
||||
placeholder={t('certSign.passwordOptional', 'Leave empty if no password')}
|
||||
value={password}
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Paper, Text, Group, Badge, Button, Divider, Modal, SegmentedControl } from '@mantine/core';
|
||||
import { useIsPhone } from '@app/hooks/useIsMobile';
|
||||
import { alert } from '@app/components/toast';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
|
||||
import { Z_INDEX_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { SessionDetail } from '@app/types/signingSession';
|
||||
import { LocalEmbedPDFWithAnnotations, SignaturePreview, AnnotationAPI } from '@app/components/viewer/LocalEmbedPDFWithAnnotations';
|
||||
import { getFileColor } from '@app/components/pageEditor/fileColors';
|
||||
import { ParticipantListPanel } from '@app/components/tools/certSign/panels/ParticipantListPanel';
|
||||
import { SessionActionsPanel } from '@app/components/tools/certSign/panels/SessionActionsPanel';
|
||||
import { AddParticipantsFlow } from '@app/components/tools/certSign/modals/AddParticipantsFlow';
|
||||
|
||||
export interface SessionDetailWorkbenchData {
|
||||
session: SessionDetail;
|
||||
pdfFile: File | null;
|
||||
onFinalize: () => Promise<void>;
|
||||
onLoadSignedPdf: () => Promise<void>;
|
||||
onAddParticipants: (userIds: number[], defaultReason?: string) => Promise<void>;
|
||||
onRemoveParticipant: (participantId: number) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onBack: () => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface SessionDetailWorkbenchViewProps {
|
||||
data: SessionDetailWorkbenchData;
|
||||
}
|
||||
|
||||
const SessionDetailWorkbenchView = ({ data }: SessionDetailWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isPhone = useIsPhone();
|
||||
const [mobilePanel, setMobilePanel] = useState<'participants' | 'pdf' | 'actions'>('pdf');
|
||||
const {
|
||||
session,
|
||||
pdfFile,
|
||||
onFinalize,
|
||||
onLoadSignedPdf,
|
||||
onAddParticipants,
|
||||
onRemoveParticipant,
|
||||
onDelete,
|
||||
onBack,
|
||||
onRefresh,
|
||||
} = data;
|
||||
|
||||
// Ref for annotation API (to access zoom controls)
|
||||
const annotationApiRef = useRef<AnnotationAPI | null>(null);
|
||||
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [addParticipantsModalOpen, setAddParticipantsModalOpen] = useState(false);
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [loadingPdf, setLoadingPdf] = useState(false);
|
||||
|
||||
// Auto-refresh every 30 seconds when not finalized
|
||||
useEffect(() => {
|
||||
if (!session.finalized) {
|
||||
const interval = setInterval(() => {
|
||||
onRefresh();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [session.finalized, onRefresh]);
|
||||
|
||||
const handleAddParticipants = async (userIds: number[], defaultReason?: string) => {
|
||||
try {
|
||||
await onAddParticipants(userIds, defaultReason);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('certSign.collab.sessionDetail.participantsAdded', 'Participants added successfully'),
|
||||
});
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.sessionDetail.addParticipantsError', 'Failed to add participants'),
|
||||
});
|
||||
throw _error; // Re-throw so modal can handle loading state
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveParticipant = async (participantId: number) => {
|
||||
try {
|
||||
await onRemoveParticipant(participantId);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('certSign.collab.sessionDetail.participantRemoved', 'Participant removed'),
|
||||
});
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.sessionDetail.removeParticipantError', 'Failed to remove participant'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
setFinalizing(true);
|
||||
try {
|
||||
await onFinalize();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.sessionDetail.finalizeError', 'Failed to finalize session'),
|
||||
});
|
||||
} finally {
|
||||
setFinalizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
setDeleteModalOpen(false);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('certSign.collab.sessionDetail.deleted', 'Session deleted'),
|
||||
});
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.sessionDetail.deleteError', 'Failed to delete session'),
|
||||
});
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSignedPdf = async () => {
|
||||
setLoadingPdf(true);
|
||||
try {
|
||||
await onLoadSignedPdf();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.sessionDetail.loadPdfError', 'Failed to load signed PDF'),
|
||||
});
|
||||
} finally {
|
||||
setLoadingPdf(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract wet signatures from all participants for preview
|
||||
const wetSignaturePreviews = useMemo<SignaturePreview[]>(() => {
|
||||
const previews: SignaturePreview[] = [];
|
||||
|
||||
session.participants.forEach((participant, participantIndex) => {
|
||||
if (participant.wetSignatures && participant.wetSignatures.length > 0) {
|
||||
const color = getFileColor(participantIndex);
|
||||
const participantName = participant.name || participant.email;
|
||||
participant.wetSignatures.forEach((wetSig, sigIndex) => {
|
||||
previews.push({
|
||||
id: `participant-${participant.userId}-sig-${sigIndex}`,
|
||||
pageIndex: wetSig.page,
|
||||
x: wetSig.x,
|
||||
y: wetSig.y,
|
||||
width: wetSig.width,
|
||||
height: wetSig.height,
|
||||
signatureData: wetSig.data,
|
||||
signatureType: 'image' as const,
|
||||
color,
|
||||
participantName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return previews;
|
||||
}, [session.participants]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Top Control Bar */}
|
||||
<Paper p="sm" shadow="sm" style={{ flexShrink: 0, zIndex: Z_INDEX_FULLSCREEN_SURFACE }}>
|
||||
<Group justify="space-between">
|
||||
<Group gap="md">
|
||||
<Button leftSection={<ArrowBackIcon />} variant="subtle" onClick={onBack} size="sm">
|
||||
{t('certSign.collab.sessionDetail.backToList', 'Back to Sessions')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<Stack gap={2}>
|
||||
<Group gap="sm">
|
||||
<Text size="sm" fw={600} style={{ maxWidth: isPhone ? '140px' : undefined }} truncate={isPhone ? 'end' : undefined}>
|
||||
{session.documentName}
|
||||
</Text>
|
||||
<Badge size="sm" color={session.finalized ? 'green' : 'blue'} variant="light">
|
||||
{session.finalized
|
||||
? t('certSign.collab.sessionList.finalized', 'Finalized')
|
||||
: t('certSign.collab.sessionList.active', 'Active')}
|
||||
</Badge>
|
||||
</Group>
|
||||
{!isPhone && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{session.ownerEmail && `${t('certSign.collab.sessionDetail.owner', 'Owner')}: ${session.ownerEmail}`}
|
||||
{session.ownerEmail && ' • '}
|
||||
{new Date(session.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{/* Zoom Controls — hidden on phone (pinch-to-zoom available) */}
|
||||
{!isPhone && (
|
||||
<Button.Group>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.zoomOut()}
|
||||
title={t('viewer.zoomOut', 'Zoom out')}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.resetZoom()}
|
||||
title={t('viewer.resetZoom', 'Reset zoom')}
|
||||
>
|
||||
<ZoomOutMapIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.zoomIn()}
|
||||
title={t('viewer.zoomIn', 'Zoom in')}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
)}
|
||||
|
||||
{/* Delete Session Button */}
|
||||
{!session.finalized && (
|
||||
<Button
|
||||
leftSection={<DeleteIcon />}
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t('certSign.collab.sessionDetail.deleteSession', 'Delete Session')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Main Content Area */}
|
||||
{isPhone ? (
|
||||
// Phone: single-panel view — all three panels stay mounted (CSS display:none preserves state)
|
||||
<div style={{ position: 'relative', flex: 1, overflow: 'hidden' }}>
|
||||
<Paper
|
||||
p="md"
|
||||
shadow="sm"
|
||||
style={{
|
||||
display: mobilePanel === 'participants' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<ParticipantListPanel
|
||||
participants={session.participants}
|
||||
finalized={session.finalized}
|
||||
onRemove={handleRemoveParticipant}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: mobilePanel === 'pdf' ? 'flex' : 'none',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
}}
|
||||
>
|
||||
<LocalEmbedPDFWithAnnotations
|
||||
ref={annotationApiRef}
|
||||
file={pdfFile ?? undefined}
|
||||
initialSignatures={wetSignaturePreviews}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Paper
|
||||
p="md"
|
||||
shadow="sm"
|
||||
style={{
|
||||
display: mobilePanel === 'actions' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<SessionActionsPanel
|
||||
session={session}
|
||||
onAddParticipants={() => setAddParticipantsModalOpen(true)}
|
||||
onFinalize={handleFinalize}
|
||||
onLoadSignedPdf={handleLoadSignedPdf}
|
||||
finalizing={finalizing}
|
||||
loadingPdf={loadingPdf}
|
||||
/>
|
||||
</Paper>
|
||||
</div>
|
||||
) : (
|
||||
// Desktop/tablet: three-column flex layout
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Left Panel - Participants */}
|
||||
<Paper
|
||||
p="md"
|
||||
shadow="sm"
|
||||
style={{
|
||||
width: '280px',
|
||||
flexShrink: 0,
|
||||
overflowY: 'auto',
|
||||
borderRight: '1px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<ParticipantListPanel
|
||||
participants={session.participants}
|
||||
finalized={session.finalized}
|
||||
onRemove={handleRemoveParticipant}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Center - PDF Viewer */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
<LocalEmbedPDFWithAnnotations
|
||||
ref={annotationApiRef}
|
||||
file={pdfFile ?? undefined}
|
||||
initialSignatures={wetSignaturePreviews}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Session Actions */}
|
||||
<Paper
|
||||
p="md"
|
||||
shadow="sm"
|
||||
style={{
|
||||
width: '320px',
|
||||
flexShrink: 0,
|
||||
overflowY: 'auto',
|
||||
borderLeft: '1px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<SessionActionsPanel
|
||||
session={session}
|
||||
onAddParticipants={() => setAddParticipantsModalOpen(true)}
|
||||
onFinalize={handleFinalize}
|
||||
onLoadSignedPdf={handleLoadSignedPdf}
|
||||
finalizing={finalizing}
|
||||
loadingPdf={loadingPdf}
|
||||
/>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone bottom navigation */}
|
||||
{isPhone && (
|
||||
<Paper shadow="sm" style={{ flexShrink: 0, borderTop: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={mobilePanel}
|
||||
onChange={(v) => setMobilePanel(v as typeof mobilePanel)}
|
||||
data={[
|
||||
{ value: 'participants', label: t('certSign.mobile.panelPeople', 'People') },
|
||||
{ value: 'pdf', label: t('certSign.mobile.panelDocument', 'Document') },
|
||||
{ value: 'actions', label: t('certSign.mobile.panelActions', 'Actions') },
|
||||
]}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Add Participants Modal */}
|
||||
<AddParticipantsFlow
|
||||
opened={addParticipantsModalOpen}
|
||||
onClose={() => setAddParticipantsModalOpen(false)}
|
||||
onSubmit={handleAddParticipants}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
opened={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title={t('certSign.collab.sessionDetail.deleteSession', 'Delete Session')}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>{t('certSign.collab.sessionDetail.deleteConfirm', 'Are you sure? This cannot be undone.')}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" onClick={() => setDeleteModalOpen(false)}>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete} loading={deleting}>
|
||||
{t('delete', 'Delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionDetailWorkbenchView;
|
||||
@@ -0,0 +1,643 @@
|
||||
.topBar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Mobile split: title-only bar at top (keep border-bottom) */
|
||||
.topBar[data-mobile-section="title"] {
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Mobile split: controls-only bar at bottom; right section fills and centers */
|
||||
.topBar[data-mobile-section="controls"] .right {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fileTitle {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.viewerFileRow {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subText {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.activeFilesRow {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fileEditorHeaderRow {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.fileEditorActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.activeFilesContent {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activeFilesSummary {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.downloadSlot {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionIcon > * {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.actionLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.viewerControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Responsive: stack controls under the title row on narrow widths */
|
||||
@media (max-width: 1200px) {
|
||||
.topBar {
|
||||
height: auto;
|
||||
min-height: 52px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Keep the long viewer toolbar usable if it still overflows */
|
||||
.viewerControls {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 2px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.viewerControls::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Save horizontal space on smaller screens */
|
||||
.zoomSlider {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
/* Keep download pinned to the far right on row 2 */
|
||||
.downloadSlot {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* In file editor (Active files), keep BOTH actions on the left */
|
||||
.topBar[data-view="fileEditor"] .downloadSlot {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.topBar[data-view="fileEditor"] .right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* When the title and controls wrap to two rows, center both rows */
|
||||
.topBar[data-wrapped="true"] .left,
|
||||
.topBar[data-wrapped="true"] .right {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topBar[data-wrapped="true"] .viewerFileRow {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* On very narrow viewports, prefer wrapping to extra rows over horizontal scrolling */
|
||||
@media (max-width: 560px) {
|
||||
.topBar[data-view="viewer"][data-mobile="false"] .right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topBar[data-view="viewer"][data-mobile="false"] .viewerControls {
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.topBar[data-view="viewer"][data-mobile="false"] .viewerControls .divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Push zoom controls to their own row when space is tight */
|
||||
.topBar[data-view="viewer"][data-mobile="false"] .zoomPill {
|
||||
flex: 1 1 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Make download drop to its own row if needed */
|
||||
.topBar[data-view="viewer"][data-mobile="false"] .downloadSlot {
|
||||
flex: 1 1 100%;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile workbench tweaks (use runtime mobile mode, not viewport width) */
|
||||
@media (max-width: 1200px) {
|
||||
.topBar[data-view="viewer"][data-mobile="true"] .downloadSlot {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.topBar[data-view="viewer"][data-mobile="true"][data-wrapped="false"] .right {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.topBar[data-view="viewer"][data-mobile="true"][data-wrapped="false"] .viewerControls {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tighten sizing specifically inside the viewer toolbar cluster */
|
||||
.viewerControls .iconButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.pagePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pageInput {
|
||||
width: 30px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pageInput::-webkit-outer-spin-button,
|
||||
.pageInput::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pageDivider {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pageTotal {
|
||||
min-width: 18px;
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zoomPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.zoomButton {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.zoomButton:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.zoomSlider {
|
||||
width: 80px;
|
||||
accent-color: var(--mantine-color-blue-6, #3b82f6);
|
||||
}
|
||||
|
||||
.zoomLabel {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border-default);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.iconButton.iconTextButton {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.iconButton.iconTextButton {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.iconButton.iconTextButton .actionLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.iconButton:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- Sign controls strip (part of normal layout) --- */
|
||||
.signStrip {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-toolbar);
|
||||
}
|
||||
|
||||
.signStrip[data-open="true"] {
|
||||
/* Always visible when open */
|
||||
}
|
||||
|
||||
.signStripInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.signStripControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.signStripOptions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.signStripActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.signStripSpacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.signStripHint {
|
||||
display: inline-flex;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.signStripButton {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.signStripPill {
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* --- Signing UI (strip) --- */
|
||||
.signingRow {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.signingLeft {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.signingCenter {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.signingMode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.signingRight {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.signingPreviewButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signingPreviewButton:hover {
|
||||
background: color-mix(in srgb, var(--bg-toolbar) 75%, transparent);
|
||||
}
|
||||
|
||||
.signingPreviewFrame {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.signingPreviewChevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.signingHint {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.signingHintUnit {
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.keyCap {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.signingStatus {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signingDivider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border-default);
|
||||
margin: 0 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.signStripCloseButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Mobile-only delete button (no Backspace key); hidden on desktop */
|
||||
.signStripMobileDelete {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sign strip mode: Place vs Move (radio-style segmented control) */
|
||||
.signStripModeRadio {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.signStripModeRadio [data-mantine-segmented-control-indicator] {
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.signingTitle {
|
||||
/* Add styles for signing title */
|
||||
}
|
||||
|
||||
/* Mobile: row 1 = close + radio (no title); row 2 = preview + apply */
|
||||
@media (max-width: 720px) {
|
||||
.signingLeft {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.signingTitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signingCenter {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.signingMode {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.signingRight {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.signStripMobileDelete {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* When the strip gets narrow, split into two rows:
|
||||
options on row 1, actions on row 2 (with divider line). */
|
||||
@media (max-width: 720px) {
|
||||
.signStripOptions {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signStripActions {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ActionIcon, Box, Button, Group, Menu, Modal, SegmentedControl, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DrawIcon from '@mui/icons-material/Draw';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import OpenWithIcon from '@mui/icons-material/OpenWith';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
|
||||
import { DEFAULT_PARAMETERS, type SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useSavedSignatures, type SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||
import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont';
|
||||
import { buildSignaturePreview } from '@app/utils/signaturePreview';
|
||||
|
||||
import styles from '@app/components/tools/certSign/SignControlsStrip.module.css';
|
||||
|
||||
interface SignControlsStripProps {
|
||||
visible: boolean;
|
||||
placementMode: boolean;
|
||||
onPlacementModeChange: (active: boolean) => void;
|
||||
onSignatureSelected: (config: SignParameters) => void;
|
||||
onComplete: () => void;
|
||||
canComplete: boolean;
|
||||
signatureConfig: SignParameters | null;
|
||||
hasSelectedAnnotation?: boolean;
|
||||
onDeleteSelected?: () => void;
|
||||
}
|
||||
|
||||
export default function SignControlsStrip({
|
||||
visible,
|
||||
placementMode,
|
||||
onPlacementModeChange,
|
||||
onSignatureSelected,
|
||||
onComplete,
|
||||
canComplete,
|
||||
signatureConfig,
|
||||
hasSelectedAnnotation = false,
|
||||
onDeleteSelected,
|
||||
}: SignControlsStripProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
savedSignatures,
|
||||
addSignature,
|
||||
removeSignature,
|
||||
isAtCapacity,
|
||||
byTypeCounts,
|
||||
} = useSavedSignatures();
|
||||
|
||||
const [createSignatureType, setCreateSignatureType] = useState<'canvas' | 'text' | 'image' | null>(null);
|
||||
const [canvasColorPickerOpen, setCanvasColorPickerOpen] = useState(false);
|
||||
const [canvasColor, setCanvasColor] = useState('#000000');
|
||||
const [canvasPenSize, setCanvasPenSize] = useState(2);
|
||||
const [canvasPenSizeInput, setCanvasPenSizeInput] = useState('2');
|
||||
const latestCanvasDataRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [textSignerName, setTextSignerName] = useState(DEFAULT_PARAMETERS.signerName ?? '');
|
||||
const [textFontFamily, setTextFontFamily] = useState(DEFAULT_PARAMETERS.fontFamily ?? 'Helvetica');
|
||||
const [textFontSize, setTextFontSize] = useState(DEFAULT_PARAMETERS.fontSize ?? 16);
|
||||
const [textColor, setTextColor] = useState(DEFAULT_PARAMETERS.textColor ?? '#000000');
|
||||
|
||||
const renderSavedSignaturePreview = useCallback(
|
||||
(sig: SavedSignature) => {
|
||||
if (sig.type === 'text') {
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 28,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
fontFamily: sig.fontFamily,
|
||||
color: sig.textColor,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{sig.signerName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 28,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2px 6px',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={sig.dataUrl}
|
||||
alt={sig.label || t('certSign.collab.signRequest.saved.defaultLabel', 'Signature')}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const sortedSavedSignatures = useMemo(() => {
|
||||
if (!savedSignatures.length) return [];
|
||||
return [...savedSignatures].sort((a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt));
|
||||
}, [savedSignatures]);
|
||||
|
||||
const hasAutoSelected = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasAutoSelected.current) return;
|
||||
if (!sortedSavedSignatures.length) return;
|
||||
if (signatureConfig?.signatureData) return;
|
||||
|
||||
hasAutoSelected.current = true;
|
||||
const lastSig = sortedSavedSignatures[0];
|
||||
if (lastSig.type === 'text') {
|
||||
onSignatureSelected({
|
||||
...DEFAULT_PARAMETERS,
|
||||
signatureType: 'text',
|
||||
signerName: lastSig.signerName,
|
||||
fontFamily: lastSig.fontFamily,
|
||||
fontSize: lastSig.fontSize,
|
||||
textColor: lastSig.textColor,
|
||||
signatureData: lastSig.dataUrl,
|
||||
});
|
||||
} else {
|
||||
onSignatureSelected({
|
||||
...DEFAULT_PARAMETERS,
|
||||
signatureType: lastSig.type,
|
||||
signatureData: lastSig.dataUrl,
|
||||
});
|
||||
}
|
||||
}, [sortedSavedSignatures, signatureConfig?.signatureData, onSignatureSelected]);
|
||||
|
||||
const beginPlacement = useCallback(
|
||||
(config: SignParameters) => {
|
||||
const nextConfig: SignParameters = {
|
||||
...DEFAULT_PARAMETERS,
|
||||
...config,
|
||||
};
|
||||
|
||||
onSignatureSelected(nextConfig);
|
||||
onPlacementModeChange(true);
|
||||
},
|
||||
[onSignatureSelected, onPlacementModeChange]
|
||||
);
|
||||
|
||||
const applySavedSignature = useCallback(
|
||||
(sig: SavedSignature) => {
|
||||
if (sig.type === 'text') {
|
||||
beginPlacement({
|
||||
signatureType: 'text',
|
||||
signerName: sig.signerName,
|
||||
fontFamily: sig.fontFamily,
|
||||
fontSize: sig.fontSize,
|
||||
textColor: sig.textColor,
|
||||
signatureData: sig.dataUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
beginPlacement({ signatureType: sig.type, signatureData: sig.dataUrl });
|
||||
},
|
||||
[beginPlacement]
|
||||
);
|
||||
|
||||
const pausePlacement = useCallback(() => {
|
||||
onPlacementModeChange(false);
|
||||
}, [onPlacementModeChange]);
|
||||
|
||||
const resumePlacement = useCallback(() => {
|
||||
onPlacementModeChange(true);
|
||||
}, [onPlacementModeChange]);
|
||||
|
||||
const handleCreateSignature = useCallback((type: 'canvas' | 'text' | 'image') => {
|
||||
if (type === 'image') {
|
||||
fileInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
setCreateSignatureType(type);
|
||||
if (type === 'canvas') {
|
||||
setCanvasColor('#000000');
|
||||
setCanvasPenSize(2);
|
||||
setCanvasPenSizeInput('2');
|
||||
latestCanvasDataRef.current = undefined;
|
||||
} else if (type === 'text') {
|
||||
setTextSignerName('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCancelCreate = useCallback(() => {
|
||||
setCreateSignatureType(null);
|
||||
}, []);
|
||||
|
||||
const saveTextToLibrary = useCallback(async () => {
|
||||
const signerName = textSignerName.trim();
|
||||
if (!signerName || isAtCapacity) return null;
|
||||
|
||||
const preview = await buildSignaturePreview({
|
||||
signatureType: 'text',
|
||||
signerName,
|
||||
fontFamily: textFontFamily,
|
||||
fontSize: textFontSize,
|
||||
textColor,
|
||||
});
|
||||
if (!preview?.dataUrl) return null;
|
||||
|
||||
const nextIndex = (byTypeCounts?.text ?? 0) + 1;
|
||||
const baseLabel = t('certSign.collab.signRequest.saved.defaultTextLabel', 'Typed signature');
|
||||
await addSignature(
|
||||
{
|
||||
type: 'text',
|
||||
dataUrl: preview.dataUrl,
|
||||
signerName,
|
||||
fontFamily: textFontFamily,
|
||||
fontSize: textFontSize,
|
||||
textColor,
|
||||
},
|
||||
`${baseLabel} ${nextIndex}`,
|
||||
'localStorage'
|
||||
);
|
||||
return {
|
||||
signerName,
|
||||
fontFamily: textFontFamily,
|
||||
fontSize: textFontSize,
|
||||
textColor,
|
||||
dataUrl: preview.dataUrl,
|
||||
};
|
||||
}, [
|
||||
addSignature,
|
||||
byTypeCounts?.text,
|
||||
isAtCapacity,
|
||||
t,
|
||||
textColor,
|
||||
textFontFamily,
|
||||
textFontSize,
|
||||
textSignerName,
|
||||
]);
|
||||
|
||||
const saveImageToLibrary = useCallback(
|
||||
async (dataUrl: string) => {
|
||||
if (!dataUrl || isAtCapacity) return;
|
||||
const nextIndex = (byTypeCounts?.image ?? 0) + 1;
|
||||
const baseLabel = t('certSign.collab.signRequest.saved.defaultImageLabel', 'Uploaded signature');
|
||||
await addSignature({ type: 'image', dataUrl }, `${baseLabel} ${nextIndex}`, 'localStorage');
|
||||
},
|
||||
[addSignature, byTypeCounts?.image, isAtCapacity, t]
|
||||
);
|
||||
|
||||
const readFileAsDataUrl = useCallback(async (file: File): Promise<string> => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const value = reader.result;
|
||||
if (typeof value === 'string') resolve(value);
|
||||
else reject(new Error('Failed to read image as data URL'));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveCanvasToLibrary = useCallback(
|
||||
async (dataUrl: string) => {
|
||||
if (!dataUrl || isAtCapacity) return;
|
||||
const nextIndex = (byTypeCounts?.canvas ?? 0) + 1;
|
||||
const baseLabel = t('certSign.collab.signRequest.saved.defaultCanvasLabel', 'Drawing signature');
|
||||
await addSignature({ type: 'canvas', dataUrl }, `${baseLabel} ${nextIndex}`, 'localStorage');
|
||||
},
|
||||
[addSignature, byTypeCounts?.canvas, isAtCapacity, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !signatureConfig) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTypingTarget =
|
||||
target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || (target as any)?.isContentEditable;
|
||||
if (isTypingTarget) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
pausePlacement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
onDeleteSelected?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [pausePlacement, onDeleteSelected, signatureConfig, visible]);
|
||||
|
||||
const handleCanvasSignatureChange = useCallback(
|
||||
(dataUrl: string | null) => {
|
||||
latestCanvasDataRef.current = dataUrl ?? undefined;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDrawingComplete = useCallback(async () => {
|
||||
const dataUrl = latestCanvasDataRef.current;
|
||||
if (!dataUrl) return;
|
||||
await saveCanvasToLibrary(dataUrl);
|
||||
beginPlacement({ signatureType: 'canvas', signatureData: dataUrl });
|
||||
setCreateSignatureType(null);
|
||||
latestCanvasDataRef.current = undefined;
|
||||
}, [saveCanvasToLibrary, beginPlacement]);
|
||||
|
||||
const handleSaveText = useCallback(async () => {
|
||||
const saved = await saveTextToLibrary();
|
||||
if (!saved) return;
|
||||
beginPlacement({
|
||||
signatureType: 'text',
|
||||
signerName: saved.signerName,
|
||||
fontFamily: saved.fontFamily,
|
||||
fontSize: saved.fontSize,
|
||||
textColor: saved.textColor,
|
||||
signatureData: saved.dataUrl,
|
||||
});
|
||||
setCreateSignatureType(null);
|
||||
setTextSignerName('');
|
||||
}, [saveTextToLibrary, beginPlacement]);
|
||||
|
||||
const handleImageSelected = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
await saveImageToLibrary(dataUrl);
|
||||
beginPlacement({ signatureType: 'image', signatureData: dataUrl });
|
||||
setCreateSignatureType(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to read signature image:', err);
|
||||
}
|
||||
},
|
||||
[readFileAsDataUrl, saveImageToLibrary, beginPlacement]
|
||||
);
|
||||
|
||||
if (!visible || !signatureConfig) return null;
|
||||
|
||||
const previewNode =
|
||||
signatureConfig.signatureType === 'text' ? (
|
||||
<div
|
||||
className={styles.signingPreviewFrame}
|
||||
style={{
|
||||
fontFamily: signatureConfig.fontFamily ?? 'Helvetica',
|
||||
color: signatureConfig.textColor ?? '#000000',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 160,
|
||||
}}
|
||||
>
|
||||
{(signatureConfig.signerName ?? '').trim() || t('certSign.collab.signRequest.preview.textFallback', 'Signature')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.signingPreviewFrame}>
|
||||
{signatureConfig.signatureData ? (
|
||||
<img
|
||||
src={signatureConfig.signatureData}
|
||||
alt={t('certSign.collab.signRequest.preview.imageAlt', 'Selected signature')}
|
||||
style={{ maxWidth: 160, maxHeight: 32, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signRequest.preview.missing', 'No preview')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.signStrip} data-open={visible ? 'true' : 'false'}>
|
||||
<div className={styles.signStripInner}>
|
||||
<div className={styles.signingRow}>
|
||||
<div className={styles.signingLeft}>
|
||||
<Text size="sm" fw={700} className={styles.signingTitle}>
|
||||
{t('certSign.collab.signRequest.signingTitle', 'Signing')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.signingCenter} aria-hidden="true" />
|
||||
|
||||
<div className={styles.signingMode}>
|
||||
<SegmentedControl
|
||||
className={styles.signStripModeRadio}
|
||||
value={placementMode ? 'place' : 'move'}
|
||||
onChange={(value) => {
|
||||
if (value === 'place') resumePlacement();
|
||||
else pausePlacement();
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: 'place',
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<DrawIcon sx={{ fontSize: '1.1rem' }} />
|
||||
<span>{t('certSign.collab.signRequest.mode.place', 'Place Signature')}</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'move',
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<OpenWithIcon sx={{ fontSize: '1.1rem' }} />
|
||||
<span>{t('certSign.collab.signRequest.mode.move', 'Move Signature')}</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
aria-label={t('certSign.collab.signRequest.mode.title', 'Sign or move mode')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.signingRight}>
|
||||
<Menu
|
||||
withinPortal
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
styles={{
|
||||
dropdown: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signingPreviewButton}
|
||||
aria-label={t('certSign.collab.signRequest.changeSignature', 'Change signature')}
|
||||
>
|
||||
{previewNode}
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{sortedSavedSignatures.length ? (
|
||||
sortedSavedSignatures.map((sig) => (
|
||||
<Menu.Item key={sig.id} onClick={() => applySavedSignature(sig)}>
|
||||
<Group gap="sm" wrap="nowrap" justify="space-between">
|
||||
{renderSavedSignaturePreview(sig)}
|
||||
<ActionIcon
|
||||
component="div"
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={(e) => { e.stopPropagation(); removeSignature(sig.id); }}
|
||||
aria-label={t('certSign.collab.signRequest.saved.delete', 'Delete signature')}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: '0.9rem' }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))
|
||||
) : (
|
||||
<Menu.Item disabled>{t('certSign.collab.signRequest.saved.none', 'No saved signatures')}</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item onClick={() => handleCreateSignature('canvas')} disabled={isAtCapacity}>
|
||||
<Group gap="xs">
|
||||
<DrawIcon sx={{ fontSize: '1rem' }} />
|
||||
<span>{t('certSign.collab.signRequest.modeTabs.draw', 'Draw')}</span>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => handleCreateSignature('text')} disabled={isAtCapacity}>
|
||||
<Group gap="xs">
|
||||
<TextFieldsIcon sx={{ fontSize: '1rem' }} />
|
||||
<span>{t('certSign.collab.signRequest.modeTabs.text', 'Type')}</span>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => handleCreateSignature('image')} disabled={isAtCapacity}>
|
||||
<Group gap="xs">
|
||||
<ImageIcon sx={{ fontSize: '1rem' }} />
|
||||
<span>{t('certSign.collab.signRequest.modeTabs.image', 'Upload')}</span>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<div className={styles.signingDivider} aria-hidden="true" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconButton} ${styles.signStripMobileDelete}`}
|
||||
onClick={onDeleteSelected}
|
||||
disabled={!hasSelectedAnnotation}
|
||||
aria-label={t('certSign.collab.signRequest.deleteSelected', 'Delete selected signature')}
|
||||
title={t('certSign.collab.signRequest.deleteSelected', 'Delete selected signature')}
|
||||
>
|
||||
<DeleteOutlineIcon sx={{ fontSize: '1.2rem' }} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconButton} ${styles.iconTextButton}`}
|
||||
onClick={onComplete}
|
||||
disabled={!canComplete}
|
||||
aria-label={t('certSign.collab.signRequest.completeAndSign', 'Complete & Sign')}
|
||||
title={t('certSign.collab.signRequest.completeAndSign', 'Complete & Sign')}
|
||||
>
|
||||
<span className={styles.actionIcon}>
|
||||
<CheckIcon sx={{ fontSize: '1.2rem' }} />
|
||||
</span>
|
||||
<span className={styles.actionLabel}>{t('certSign.collab.signRequest.completeAndSign', 'Complete & Sign')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draw Signature — auto-opens its inner canvas modal directly */}
|
||||
{createSignatureType === 'canvas' && (
|
||||
<DrawingCanvas
|
||||
autoOpen
|
||||
selectedColor={canvasColor}
|
||||
penSize={canvasPenSize}
|
||||
penSizeInput={canvasPenSizeInput}
|
||||
onColorSwatchClick={() => setCanvasColorPickerOpen(true)}
|
||||
onPenSizeChange={(size) => {
|
||||
setCanvasPenSize(size);
|
||||
setCanvasPenSizeInput(String(size));
|
||||
}}
|
||||
onPenSizeInputChange={(input) => {
|
||||
setCanvasPenSizeInput(input);
|
||||
const next = Number(input);
|
||||
if (Number.isFinite(next) && next > 0 && next <= 50) {
|
||||
setCanvasPenSize(next);
|
||||
}
|
||||
}}
|
||||
onSignatureDataChange={handleCanvasSignatureChange}
|
||||
onDrawingComplete={handleDrawingComplete}
|
||||
onModalClose={handleCancelCreate}
|
||||
width={600}
|
||||
height={200}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type Signature Modal */}
|
||||
<Modal
|
||||
opened={createSignatureType === 'text'}
|
||||
onClose={handleCancelCreate}
|
||||
title={t('certSign.collab.signRequest.modeTabs.text', 'Type Signature')}
|
||||
size="md"
|
||||
withinPortal
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('certSign.collab.signRequest.text.modalHint', 'Enter your name, then click Continue to place it on the PDF.')}
|
||||
</Text>
|
||||
<TextInputWithFont
|
||||
text={textSignerName}
|
||||
onTextChange={setTextSignerName}
|
||||
fontFamily={textFontFamily}
|
||||
onFontFamilyChange={setTextFontFamily}
|
||||
fontSize={textFontSize}
|
||||
onFontSizeChange={setTextFontSize}
|
||||
textColor={textColor}
|
||||
onTextColorChange={setTextColor}
|
||||
label={t('certSign.collab.signRequest.text.label', 'Signature Text')}
|
||||
placeholder={t('certSign.collab.signRequest.text.placeholder', 'Enter your name...')}
|
||||
fontLabel={t('certSign.collab.signRequest.text.fontLabel', 'Font')}
|
||||
fontSizeLabel={t('certSign.collab.signRequest.text.fontSizeLabel', 'Size')}
|
||||
fontSizePlaceholder={t('certSign.collab.signRequest.text.fontSizePlaceholder', '16')}
|
||||
colorLabel={t('certSign.collab.signRequest.text.colorLabel', 'Color')}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" onClick={handleCancelCreate}>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSaveText} disabled={!textSignerName.trim()}>
|
||||
{t('certSign.collab.signRequest.canvas.continue', 'Continue')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{canvasColorPickerOpen && (
|
||||
<ColorPicker
|
||||
isOpen={canvasColorPickerOpen}
|
||||
onClose={() => setCanvasColorPickerOpen(false)}
|
||||
selectedColor={canvasColor}
|
||||
onColorChange={setCanvasColor}
|
||||
title={t('certSign.collab.signRequest.canvas.colorPickerTitle', 'Choose stroke colour')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageSelected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Paper, Group, Button, Text, Divider, CloseButton } from '@mantine/core';
|
||||
import { useIsPhone } from '@app/hooks/useIsMobile';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { Z_INDEX_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { SignRequestDetail } from '@app/types/signingSession';
|
||||
import { LocalEmbedPDFWithAnnotations, AnnotationAPI } from '@app/components/viewer/LocalEmbedPDFWithAnnotations';
|
||||
import { alert } from '@app/components/toast';
|
||||
import SignControlsStrip from '@app/components/tools/certSign/SignControlsStrip';
|
||||
import { CertificateConfigModal } from '@app/components/tools/certSign/modals/CertificateConfigModal';
|
||||
import type { CertificateSubmitData } from '@app/components/tools/certSign/modals/CertificateConfigModal';
|
||||
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useFileActions } from '@app/contexts/file/fileHooks';
|
||||
|
||||
export interface SignRequestWorkbenchData {
|
||||
signRequest: SignRequestDetail;
|
||||
pdfFile: File;
|
||||
onSign: (certificateData: FormData) => Promise<void>;
|
||||
onDecline: () => Promise<void>;
|
||||
onBack: () => void;
|
||||
canSign: boolean;
|
||||
}
|
||||
|
||||
interface SignRequestWorkbenchViewProps {
|
||||
data: SignRequestWorkbenchData;
|
||||
}
|
||||
|
||||
const SignRequestWorkbenchView = ({ data }: SignRequestWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isPhone = useIsPhone();
|
||||
const { signRequest, pdfFile, onSign, onDecline, onBack, canSign } = data;
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
// Ref for annotation API
|
||||
const annotationApiRef = useRef<AnnotationAPI | null>(null);
|
||||
|
||||
// Signature state - start with default config if user can sign
|
||||
const [signatureConfig, setSignatureConfig] = useState<SignParameters | null>(
|
||||
canSign
|
||||
? {
|
||||
signatureType: 'canvas',
|
||||
signerName: '',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 16,
|
||||
textColor: '#000000',
|
||||
}
|
||||
: null
|
||||
);
|
||||
const [previewCount, setPreviewCount] = useState(0);
|
||||
const [placementMode, setPlacementMode] = useState(true);
|
||||
const [hasSelectedAnnotation, setHasSelectedAnnotation] = useState(false);
|
||||
|
||||
// Certificate modal state
|
||||
const [certificateModalOpen, setCertificateModalOpen] = useState(false);
|
||||
|
||||
// Process state
|
||||
const [signing, setSigning] = useState(false);
|
||||
const [declining, setDeclining] = useState(false);
|
||||
|
||||
// Show/hide sign controls strip - always visible when user can sign
|
||||
const signControlsVisible = canSign && signatureConfig !== null;
|
||||
|
||||
// Check for selected annotation periodically
|
||||
useEffect(() => {
|
||||
if (!signControlsVisible || !annotationApiRef.current) {
|
||||
setHasSelectedAnnotation(false);
|
||||
return;
|
||||
}
|
||||
const check = () => {
|
||||
const has = (annotationApiRef.current as any)?.getHasSelectedAnnotation?.();
|
||||
setHasSelectedAnnotation(Boolean(has));
|
||||
};
|
||||
check();
|
||||
const id = setInterval(check, 350);
|
||||
return () => clearInterval(id);
|
||||
}, [signControlsVisible]);
|
||||
|
||||
const handleSignatureSelected = (config: SignParameters) => {
|
||||
setSignatureConfig(config);
|
||||
};
|
||||
|
||||
const handleOpenCertificateModal = () => {
|
||||
if (previewCount === 0) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.signRequest.noSignatures', 'Please place at least one signature on the PDF'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCertificateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSign = async (certData: CertificateSubmitData, reason?: string, location?: string) => {
|
||||
const previews = annotationApiRef.current?.getSignaturePreviews() || [];
|
||||
console.log('handleSign called, previews:', previews.length, 'signatures');
|
||||
|
||||
setSigning(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
if (certData.certType === 'UPLOAD') {
|
||||
const { uploadFormat, p12File, privateKeyFile, certFile, jksFile, password } = certData;
|
||||
formData.append('certType', uploadFormat);
|
||||
switch (uploadFormat) {
|
||||
case 'PKCS12':
|
||||
case 'PFX':
|
||||
if (!p12File) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'),
|
||||
});
|
||||
setSigning(false);
|
||||
return;
|
||||
}
|
||||
formData.append('p12File', p12File);
|
||||
break;
|
||||
case 'PEM':
|
||||
if (!privateKeyFile || !certFile) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'),
|
||||
});
|
||||
setSigning(false);
|
||||
return;
|
||||
}
|
||||
formData.append('privateKeyFile', privateKeyFile);
|
||||
formData.append('certFile', certFile);
|
||||
break;
|
||||
case 'JKS':
|
||||
if (!jksFile) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'),
|
||||
});
|
||||
setSigning(false);
|
||||
return;
|
||||
}
|
||||
formData.append('jksFile', jksFile);
|
||||
break;
|
||||
}
|
||||
if (password) {
|
||||
formData.append('password', password);
|
||||
}
|
||||
} else {
|
||||
formData.append('certType', certData.certType);
|
||||
}
|
||||
|
||||
// Add signature appearance settings from sign request
|
||||
if (signRequest.showSignature !== undefined) {
|
||||
formData.append('showSignature', signRequest.showSignature.toString());
|
||||
}
|
||||
if (signRequest.pageNumber !== undefined && signRequest.pageNumber !== null) {
|
||||
formData.append('pageNumber', signRequest.pageNumber.toString());
|
||||
}
|
||||
|
||||
// Participant-provided reason/location override session defaults
|
||||
if (reason && reason.trim()) {
|
||||
formData.append('reason', reason);
|
||||
} else if (signRequest.reason) {
|
||||
formData.append('reason', signRequest.reason);
|
||||
}
|
||||
|
||||
if (location && location.trim()) {
|
||||
formData.append('location', location);
|
||||
} else if (signRequest.location) {
|
||||
formData.append('location', signRequest.location);
|
||||
}
|
||||
|
||||
if (signRequest.showLogo !== undefined) {
|
||||
formData.append('showLogo', signRequest.showLogo.toString());
|
||||
}
|
||||
|
||||
// Add all wet signatures from previews
|
||||
if (previews.length > 0) {
|
||||
const wetSignaturesJson = previews.map((preview) => ({
|
||||
type: preview.signatureType,
|
||||
data: preview.signatureData,
|
||||
page: preview.pageIndex,
|
||||
x: preview.x,
|
||||
y: preview.y,
|
||||
width: preview.width,
|
||||
height: preview.height,
|
||||
}));
|
||||
|
||||
console.log('Sending wet signatures to backend:', wetSignaturesJson.length, 'signatures');
|
||||
formData.append('wetSignaturesData', JSON.stringify(wetSignaturesJson));
|
||||
}
|
||||
|
||||
await onSign(formData);
|
||||
setCertificateModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to sign document:', error);
|
||||
} finally {
|
||||
setSigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
setDeclining(true);
|
||||
try {
|
||||
await onDecline();
|
||||
} catch (error) {
|
||||
console.error('Failed to decline request:', error);
|
||||
setDeclining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToActiveFiles = async () => {
|
||||
await fileActions.addFiles([pdfFile]);
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('success'),
|
||||
body: t('certSign.collab.signRequest.addedToFiles', 'Document added to active files'),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
(annotationApiRef.current as any)?.deleteSelectedAnnotation?.();
|
||||
};
|
||||
|
||||
const handlePlaceSignature = (
|
||||
id: string,
|
||||
pageIndex: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
console.log('Signature placed:', { id, pageIndex, x, y, width, height });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Top Control Bar */}
|
||||
<Paper p="sm" shadow="sm" style={{ flexShrink: 0, zIndex: Z_INDEX_FULLSCREEN_SURFACE, position: 'relative' }}>
|
||||
<Group justify="space-between" style={{ flexWrap: isPhone ? 'wrap' : 'nowrap' }}>
|
||||
<Group gap="md">
|
||||
<LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />
|
||||
<div>
|
||||
<Text size="sm" fw={600} style={{ maxWidth: isPhone ? '180px' : undefined }} truncate={isPhone ? 'end' : undefined}>
|
||||
{signRequest.documentName}
|
||||
</Text>
|
||||
{!isPhone && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signRequest.from', 'From')}: {signRequest.ownerUsername} •{' '}
|
||||
{new Date(signRequest.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" style={{ width: isPhone ? '100%' : undefined }} justify={isPhone ? 'flex-end' : undefined}>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<FolderOpenIcon fontSize="small" />}
|
||||
onClick={handleAddToActiveFiles}
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-inner-paper-bg)',
|
||||
color: 'var(--btn-open-file)',
|
||||
border: '1px solid var(--landing-inner-paper-border)',
|
||||
}}
|
||||
>
|
||||
{t('certSign.collab.signRequest.addToFiles', 'Add to Active Files')}
|
||||
</Button>
|
||||
{signRequest.myStatus !== 'SIGNED' && signRequest.myStatus !== 'DECLINED' && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<CancelIcon fontSize="small" />}
|
||||
onClick={handleDecline}
|
||||
loading={declining}
|
||||
>
|
||||
{t('certSign.collab.signRequest.decline', 'Decline Request')}
|
||||
</Button>
|
||||
)}
|
||||
{!isPhone && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<Button.Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.zoomOut()}
|
||||
title={t('viewer.zoomOut', 'Zoom out')}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.resetZoom()}
|
||||
title={t('viewer.resetZoom', 'Reset zoom')}
|
||||
>
|
||||
<ZoomOutMapIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.zoomIn()}
|
||||
title={t('viewer.zoomIn', 'Zoom in')}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</>
|
||||
)}
|
||||
<Divider orientation="vertical" />
|
||||
<CloseButton size="md" onClick={onBack} title={t('certSign.collab.signRequest.backToList', 'Back to Sign Requests')} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Sign Controls Strip - always shown when user can sign */}
|
||||
{canSign && signControlsVisible && (
|
||||
<SignControlsStrip
|
||||
visible={signControlsVisible}
|
||||
placementMode={placementMode}
|
||||
onPlacementModeChange={setPlacementMode}
|
||||
onSignatureSelected={handleSignatureSelected}
|
||||
onComplete={handleOpenCertificateModal}
|
||||
canComplete={previewCount > 0}
|
||||
signatureConfig={signatureConfig}
|
||||
hasSelectedAnnotation={hasSelectedAnnotation}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PDF Viewer (full width) */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
<LocalEmbedPDFWithAnnotations
|
||||
ref={annotationApiRef}
|
||||
file={pdfFile}
|
||||
onAnnotationChange={() => {}}
|
||||
placementMode={placementMode}
|
||||
signatureData={signatureConfig?.signatureData}
|
||||
signatureType={signatureConfig?.signatureType}
|
||||
onPlaceSignature={handlePlaceSignature}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Certificate Configuration Modal */}
|
||||
{canSign && (
|
||||
<CertificateConfigModal
|
||||
opened={certificateModalOpen}
|
||||
onClose={() => setCertificateModalOpen(false)}
|
||||
onSign={handleSign}
|
||||
signatureCount={previewCount}
|
||||
disabled={signing}
|
||||
defaultReason={signRequest.reason || ''}
|
||||
defaultLocation={signRequest.location || ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignRequestWorkbenchView;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Paper, Text, Group, Badge } from '@mantine/core';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface SignatureSettingsDisplayProps {
|
||||
showSignature: boolean;
|
||||
pageNumber?: number | null;
|
||||
reason?: string | null;
|
||||
location?: string | null;
|
||||
showLogo: boolean;
|
||||
}
|
||||
|
||||
const SignatureSettingsDisplay = ({
|
||||
showSignature,
|
||||
pageNumber,
|
||||
reason,
|
||||
location,
|
||||
showLogo,
|
||||
}: SignatureSettingsDisplayProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Paper p="sm" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.appearance.visibility', 'Visibility')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{showSignature ? (
|
||||
<>
|
||||
<VisibilityIcon style={{ fontSize: '16px', color: 'var(--mantine-color-green-6)' }} />
|
||||
<Badge size="sm" color="green" variant="light">
|
||||
{t('certSign.appearance.visible', 'Visible')}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<VisibilityOffIcon style={{ fontSize: '16px', color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
{t('certSign.appearance.invisible', 'Invisible')}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{showSignature && (
|
||||
<>
|
||||
{pageNumber && (
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.pageNumber', 'Page Number')}
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{pageNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{reason && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.reason', 'Reason')}
|
||||
</Text>
|
||||
<Paper p="xs" withBorder bg="gray.0">
|
||||
<Text size="xs">{reason}</Text>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{location && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.location', 'Location')}
|
||||
</Text>
|
||||
<Paper p="xs" withBorder bg="gray.0">
|
||||
<Text size="xs">{location}</Text>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.logoTitle', 'Logo')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{showLogo ? (
|
||||
<>
|
||||
<CheckIcon style={{ fontSize: '16px', color: 'var(--mantine-color-green-6)' }} />
|
||||
<Badge size="sm" color="green" variant="light">
|
||||
{t('certSign.showLogo', 'Show Logo')}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloseIcon style={{ fontSize: '16px', color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
{t('certSign.noLogo', 'No Logo')}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper p="xs" withBorder bg="blue.0">
|
||||
<Text size="xs" c="blue.9">
|
||||
{t(
|
||||
'certSign.collab.signRequest.signatureInfo',
|
||||
'These settings are configured by the document owner'
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureSettingsDisplay;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Stack, Text, Button, TextInput, NumberInput, Switch } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SignatureSettings {
|
||||
showSignature?: boolean;
|
||||
pageNumber?: number;
|
||||
reason?: string;
|
||||
location?: string;
|
||||
showLogo?: boolean;
|
||||
includeSummaryPage?: boolean;
|
||||
}
|
||||
|
||||
interface SignatureSettingsInputProps {
|
||||
value: SignatureSettings;
|
||||
onChange: (settings: SignatureSettings) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SignatureSettingsInput = ({ value, onChange, disabled = false }: SignatureSettingsInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (key: keyof SignatureSettings, val: any) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.signatureSettings.title', 'Signature Appearance')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signatureSettings.description', 'Configure how signatures will appear for all participants')}
|
||||
</Text>
|
||||
|
||||
{/* Signature Visibility */}
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={!value.showSignature ? 'filled' : 'outline'}
|
||||
color={!value.showSignature ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => handleChange('showSignature', false)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.appearance.invisible', 'Invisible')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={value.showSignature ? 'filled' : 'outline'}
|
||||
color={value.showSignature ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => handleChange('showSignature', true)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.appearance.visible', 'Visible')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Visible Signature Options */}
|
||||
{value.showSignature && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('certSign.reason', 'Reason')}
|
||||
value={value.reason || ''}
|
||||
onChange={(event) => handleChange('reason', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('certSign.location', 'Location')}
|
||||
value={value.location || ''}
|
||||
onChange={(event) => handleChange('location', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
size="xs"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('certSign.pageNumber', 'Page Number')}
|
||||
value={value.pageNumber || 1}
|
||||
onChange={(val) => handleChange('pageNumber', val || 1)}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
size="xs"
|
||||
/>
|
||||
<Switch
|
||||
label={t('certSign.showLogo', 'Show Stirling PDF Logo')}
|
||||
checked={value.showLogo || false}
|
||||
onChange={(event) => handleChange('showLogo', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Summary Page Toggle */}
|
||||
<Switch
|
||||
label={t('certSign.collab.sessionCreation.includeSummaryPage', 'Include Signature Summary Page')}
|
||||
description={t('certSign.collab.sessionCreation.includeSummaryPageHelp', 'A summary page will be added at the end with all signature metadata. The digital certificate signature boxes on individual pages will be suppressed (wet signatures are unaffected).')}
|
||||
checked={value.includeSummaryPage || false}
|
||||
onChange={(event) => handleChange('includeSummaryPage', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureSettingsInput;
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
SegmentedControl,
|
||||
Text,
|
||||
Radio,
|
||||
FileInput,
|
||||
PasswordInput,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas';
|
||||
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
|
||||
import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
|
||||
type SignatureType = 'canvas' | 'image' | 'text';
|
||||
type CertificateType = 'SERVER' | 'USER_CERT' | 'UPLOAD';
|
||||
|
||||
interface WetSignatureInputProps {
|
||||
onSignatureDataChange: (data: string | undefined) => void;
|
||||
onSignatureTypeChange: (type: SignatureType) => void;
|
||||
onCertTypeChange: (type: CertificateType) => void;
|
||||
onP12FileChange: (file: File | null) => void;
|
||||
onPasswordChange: (password: string) => void;
|
||||
certType: CertificateType;
|
||||
p12File: File | null;
|
||||
password: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const WetSignatureInput = ({
|
||||
onSignatureDataChange,
|
||||
onSignatureTypeChange,
|
||||
onCertTypeChange,
|
||||
onP12FileChange,
|
||||
onPasswordChange,
|
||||
certType,
|
||||
p12File,
|
||||
password,
|
||||
disabled = false,
|
||||
}: WetSignatureInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Signature type state
|
||||
const [signatureType, setSignatureType] = useState<SignatureType>('canvas');
|
||||
|
||||
// Canvas drawing state
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<string | undefined>();
|
||||
|
||||
// Image upload state
|
||||
const [imageSignatureData, setImageSignatureData] = useState<string | undefined>();
|
||||
|
||||
// Text signature state
|
||||
const [signerName, setSignerName] = useState('');
|
||||
const [fontSize, setFontSize] = useState(16);
|
||||
const [fontFamily, setFontFamily] = useState('Helvetica');
|
||||
const [textColor, setTextColor] = useState('#000000');
|
||||
|
||||
// Handle signature type change
|
||||
const handleSignatureTypeChange = useCallback(
|
||||
(type: SignatureType) => {
|
||||
setSignatureType(type);
|
||||
onSignatureTypeChange(type);
|
||||
|
||||
// Update signature data based on type
|
||||
if (type === 'canvas') {
|
||||
onSignatureDataChange(canvasSignatureData);
|
||||
} else if (type === 'image') {
|
||||
onSignatureDataChange(imageSignatureData);
|
||||
} else if (type === 'text') {
|
||||
// For text signatures, we pass the signer name
|
||||
onSignatureDataChange(signerName || undefined);
|
||||
}
|
||||
},
|
||||
[canvasSignatureData, imageSignatureData, signerName, onSignatureTypeChange, onSignatureDataChange]
|
||||
);
|
||||
|
||||
// Handle canvas signature change
|
||||
const handleCanvasSignatureChange = useCallback(
|
||||
(data: string | null) => {
|
||||
const nextValue = data ?? undefined;
|
||||
setCanvasSignatureData(nextValue);
|
||||
if (signatureType === 'canvas') {
|
||||
onSignatureDataChange(nextValue);
|
||||
}
|
||||
},
|
||||
[signatureType, onSignatureDataChange]
|
||||
);
|
||||
|
||||
// Handle image upload
|
||||
const handleImageChange = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
setImageSignatureData(result);
|
||||
if (signatureType === 'image') {
|
||||
onSignatureDataChange(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageSignatureData(undefined);
|
||||
if (signatureType === 'image') {
|
||||
onSignatureDataChange(undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, signatureType, onSignatureDataChange]
|
||||
);
|
||||
|
||||
// Handle text signature changes
|
||||
useEffect(() => {
|
||||
if (signatureType === 'text') {
|
||||
onSignatureDataChange(signerName || undefined);
|
||||
}
|
||||
}, [signatureType, signerName, onSignatureDataChange]);
|
||||
|
||||
const renderSignatureBuilder = () => {
|
||||
if (signatureType === 'canvas') {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signRequest.drawSignature', 'Draw your signature below')}
|
||||
</Text>
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => setIsColorPickerOpen(true)}
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={handleCanvasSignatureChange}
|
||||
onDrawingComplete={() => {}}
|
||||
disabled={disabled}
|
||||
initialSignatureData={canvasSignatureData}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureType === 'image') {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signRequest.uploadSignature', 'Upload your signature image')}
|
||||
</Text>
|
||||
<ImageUploader onImageChange={handleImageChange} disabled={disabled} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.collab.signRequest.typeSignature', 'Type your name to create a signature')}
|
||||
</Text>
|
||||
<TextInputWithFont
|
||||
text={signerName}
|
||||
onTextChange={setSignerName}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={setFontSize}
|
||||
fontFamily={fontFamily}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
textColor={textColor}
|
||||
onTextColorChange={setTextColor}
|
||||
disabled={disabled}
|
||||
onAnyChange={() => {}}
|
||||
label={t('certSign.collab.signRequest.signatureText', 'Signature Text')}
|
||||
placeholder={t('certSign.collab.signRequest.signatureTextPlaceholder', 'Enter your name...')}
|
||||
fontLabel={t('certSign.collab.signRequest.fontFamily', 'Font Family')}
|
||||
fontSizeLabel={t('certSign.collab.signRequest.fontSize', 'Font Size')}
|
||||
fontSizePlaceholder={t('certSign.collab.signRequest.fontSizePlaceholder', 'Size')}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Signature Type Selector */}
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.signRequest.signatureTypeLabel', 'Signature Type')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={signatureType}
|
||||
fullWidth
|
||||
onChange={(value) => handleSignatureTypeChange(value as SignatureType)}
|
||||
data={[
|
||||
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' },
|
||||
{ label: t('sign.type.image', 'Upload'), value: 'image' },
|
||||
{ label: t('sign.type.text', 'Type'), value: 'text' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Signature Builder */}
|
||||
{renderSignatureBuilder()}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Certificate Selection */}
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.signRequest.certificateChoice', 'Certificate Choice')}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={certType}
|
||||
onChange={(value) => onCertTypeChange(value as CertificateType)}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Radio
|
||||
value="USER_CERT"
|
||||
label={t('certSign.collab.signRequest.usePersonalCert', 'Use My Personal Certificate')}
|
||||
description={t('certSign.collab.signRequest.usePersonalCertDesc', 'Auto-generated for your account')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Radio
|
||||
value="SERVER"
|
||||
label={t('certSign.collab.signRequest.useServerCert', 'Use Organization Certificate')}
|
||||
description={t('certSign.collab.signRequest.useServerCertDesc', 'Shared organization certificate')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Radio
|
||||
value="UPLOAD"
|
||||
label={t('certSign.collab.signRequest.uploadCert', 'Upload Custom Certificate')}
|
||||
description={t('certSign.collab.signRequest.uploadCertDesc', 'Use your own PKCS12 certificate')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{certType === 'UPLOAD' && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<FileInput
|
||||
label={t('certSign.collab.signRequest.p12File', 'P12/PFX Certificate File')}
|
||||
placeholder={t('certSign.collab.signRequest.selectFile', 'Select file...')}
|
||||
accept=".p12,.pfx"
|
||||
value={p12File}
|
||||
onChange={onP12FileChange}
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('certSign.collab.signRequest.password', 'Certificate Password')}
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.currentTarget.value)}
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WetSignatureInput;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Stack, TextInput, Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import UserSelector from '@app/components/shared/UserSelector';
|
||||
|
||||
interface AddParticipantsFlowProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (userIds: number[], defaultReason?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [defaultReason, setDefaultReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedUserIds([]);
|
||||
setDefaultReason('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(selectedUserIds, defaultReason.trim() || undefined);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add participants:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t('certSign.collab.sessionDetail.addParticipants', 'Add Participants')}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<UserSelector
|
||||
value={selectedUserIds}
|
||||
onChange={setSelectedUserIds}
|
||||
placeholder={t('certSign.collab.sessionDetail.selectUsers', 'Select users...')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('certSign.reason', 'Default Reason')}
|
||||
description={t('certSign.collab.addParticipants.reasonHelp', 'Pre-set a signing reason for these participants (optional, they can override when signing)')}
|
||||
value={defaultReason}
|
||||
onChange={(e) => setDefaultReason(e.currentTarget.value)}
|
||||
placeholder={t('certSign.collab.addParticipants.reasonPlaceholder', 'e.g. Approval, Review...')}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={selectedUserIds.length === 0}
|
||||
leftSection={<AddIcon sx={{ fontSize: 16 }} />}
|
||||
color="green"
|
||||
>
|
||||
{t('certSign.collab.addParticipants.add', 'Add {{count}} Participant(s)', {
|
||||
count: selectedUserIds.length,
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Modal, Stack, Group, Button, Text, Collapse, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector';
|
||||
|
||||
export interface CertificateSubmitData {
|
||||
certType: CertificateType;
|
||||
uploadFormat: UploadFormat;
|
||||
p12File: File | null;
|
||||
privateKeyFile: File | null;
|
||||
certFile: File | null;
|
||||
jksFile: File | null;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface CertificateConfigModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSign: (certData: CertificateSubmitData, reason?: string, location?: string) => Promise<void>;
|
||||
signatureCount: number;
|
||||
disabled?: boolean;
|
||||
defaultReason?: string;
|
||||
defaultLocation?: string;
|
||||
}
|
||||
|
||||
export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSign,
|
||||
signatureCount,
|
||||
disabled = false,
|
||||
defaultReason = '',
|
||||
defaultLocation = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [certType, setCertType] = useState<CertificateType>('USER_CERT');
|
||||
const [uploadFormat, setUploadFormat] = useState<UploadFormat>('PKCS12');
|
||||
const [p12File, setP12File] = useState<File | null>(null);
|
||||
const [privateKeyFile, setPrivateKeyFile] = useState<File | null>(null);
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [jksFile, setJksFile] = useState<File | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [signing, setSigning] = useState(false);
|
||||
|
||||
// Advanced settings
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [reason, setReason] = useState(defaultReason);
|
||||
const [location, setLocation] = useState(defaultLocation);
|
||||
|
||||
const isUploadValid = () => {
|
||||
if (certType !== 'UPLOAD') return true;
|
||||
switch (uploadFormat) {
|
||||
case 'PKCS12':
|
||||
case 'PFX':
|
||||
return p12File !== null;
|
||||
case 'PEM':
|
||||
return privateKeyFile !== null && certFile !== null;
|
||||
case 'JKS':
|
||||
return jksFile !== null;
|
||||
}
|
||||
};
|
||||
|
||||
const isValid =
|
||||
certType === 'USER_CERT' ||
|
||||
certType === 'SERVER' ||
|
||||
isUploadValid();
|
||||
|
||||
const handleSign = async () => {
|
||||
if (!isValid) return;
|
||||
|
||||
setSigning(true);
|
||||
try {
|
||||
await onSign(
|
||||
{ certType, uploadFormat, p12File, privateKeyFile, certFile, jksFile, password },
|
||||
reason,
|
||||
location
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to sign document:', error);
|
||||
} finally {
|
||||
setSigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('certSign.collab.signRequest.certModal.title', 'Configure Certificate')}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'certSign.collab.signRequest.certModal.description',
|
||||
'You have placed {{count}} signature(s). Choose your certificate to complete signing.',
|
||||
{ count: signatureCount }
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<CertificateSelector
|
||||
certType={certType}
|
||||
onCertTypeChange={setCertType}
|
||||
uploadFormat={uploadFormat}
|
||||
onUploadFormatChange={setUploadFormat}
|
||||
p12File={p12File}
|
||||
onP12FileChange={setP12File}
|
||||
privateKeyFile={privateKeyFile}
|
||||
onPrivateKeyFileChange={setPrivateKeyFile}
|
||||
certFile={certFile}
|
||||
onCertFileChange={setCertFile}
|
||||
jksFile={jksFile}
|
||||
onJksFileChange={setJksFile}
|
||||
password={password}
|
||||
onPasswordChange={setPassword}
|
||||
disabled={disabled || signing}
|
||||
/>
|
||||
|
||||
{/* Advanced Settings - Optional */}
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
disabled={disabled || signing}
|
||||
style={{ marginBottom: '8px' }}
|
||||
>
|
||||
{t('certSign.collab.signRequest.advancedSettings', 'Advanced Settings')}
|
||||
</Button>
|
||||
|
||||
<Collapse in={showAdvanced}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('certSign.collab.signRequest.reason', 'Reason (Optional)')}
|
||||
placeholder={t('certSign.collab.signRequest.reasonPlaceholder', 'Why are you signing?')}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
disabled={disabled || signing}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('certSign.collab.signRequest.location', 'Location (Optional)')}
|
||||
placeholder={t('certSign.collab.signRequest.locationPlaceholder', 'Where are you signing from?')}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.currentTarget.value)}
|
||||
disabled={disabled || signing}
|
||||
/>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<Group justify="space-between" wrap="wrap" mt="md">
|
||||
<Button variant="default" onClick={onClose} disabled={signing}>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSign}
|
||||
disabled={!isValid || disabled || signing}
|
||||
loading={signing}
|
||||
>
|
||||
{t('certSign.collab.signRequest.certModal.sign', 'Sign Document')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Modal, Stack, Button, Text, Group, Box, ActionIcon, UnstyledButton } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSavedSignatures, SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||
import DrawIcon from '@mui/icons-material/Draw';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface SelectSignatureModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSignatureSelected: (signature: SavedSignature) => void;
|
||||
onCreateNew: (type: 'canvas' | 'text' | 'image') => void;
|
||||
}
|
||||
|
||||
export const SelectSignatureModal: React.FC<SelectSignatureModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSignatureSelected,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { savedSignatures, removeSignature } = useSavedSignatures();
|
||||
|
||||
const sortedSavedSignatures = [...savedSignatures].sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt)
|
||||
);
|
||||
|
||||
const renderSignaturePreview = (sig: SavedSignature) => {
|
||||
if (sig.type === 'text') {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: 72,
|
||||
height: 28,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
fontFamily: sig.fontFamily,
|
||||
color: sig.textColor,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{sig.signerName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: 72,
|
||||
height: 28,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={sig.dataUrl}
|
||||
alt={sig.label || t('certSign.collab.signRequest.saved.defaultLabel', 'Signature')}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('certSign.collab.signRequest.selectSignatureTitle', 'Select or Create Signature')}
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{sortedSavedSignatures.length > 0 && (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.signRequest.savedSignatures', 'Saved Signatures')}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{sortedSavedSignatures.map((sig) => (
|
||||
<Group
|
||||
key={sig.id}
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8, overflow: 'hidden' }}
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => { onSignatureSelected(sig); onClose(); }}
|
||||
style={{ flex: 1, padding: '12px' }}
|
||||
>
|
||||
{renderSignaturePreview(sig)}
|
||||
</UnstyledButton>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => removeSignature(sig.id)}
|
||||
aria-label={t('certSign.collab.signRequest.saved.delete', 'Delete signature')}
|
||||
style={{ margin: '0 6px' }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: '1rem' }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={600} mt={sortedSavedSignatures.length > 0 ? 'md' : 0}>
|
||||
{t('certSign.collab.signRequest.createNewSignature', 'Create New Signature')}
|
||||
</Text>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<DrawIcon />}
|
||||
onClick={() => {
|
||||
onCreateNew('canvas');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('certSign.collab.signRequest.modeTabs.draw', 'Draw')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<TextFieldsIcon />}
|
||||
onClick={() => {
|
||||
onCreateNew('text');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('certSign.collab.signRequest.modeTabs.text', 'Type')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<ImageIcon />}
|
||||
onClick={() => {
|
||||
onCreateNew('image');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('certSign.collab.signRequest.modeTabs.image', 'Upload')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Stack, Text, List, Group, Badge, ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import PendingIcon from '@mui/icons-material/Pending';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import type { ParticipantInfo } from '@app/types/signingSession';
|
||||
import { getFileColor } from '@app/components/pageEditor/fileColors';
|
||||
|
||||
interface ParticipantListPanelProps {
|
||||
participants: ParticipantInfo[];
|
||||
finalized: boolean;
|
||||
onRemove: (participantId: number) => void;
|
||||
}
|
||||
|
||||
export const ParticipantListPanel: React.FC<ParticipantListPanelProps> = ({
|
||||
participants,
|
||||
finalized,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getIcon = (status: string) => {
|
||||
if (status === 'SIGNED') return <CheckCircleIcon sx={{ color: 'green', fontSize: '1rem' }} />;
|
||||
if (status === 'DECLINED') return <CancelIcon sx={{ color: 'red', fontSize: '1rem' }} />;
|
||||
return <PendingIcon sx={{ color: 'orange', fontSize: '1rem' }} />;
|
||||
};
|
||||
|
||||
const getColor = (status: string) => {
|
||||
if (status === 'SIGNED') return 'green';
|
||||
if (status === 'DECLINED') return 'red';
|
||||
return 'orange';
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="md" fw={600}>
|
||||
{t('certSign.collab.sessionDetail.participants', 'Participants')}
|
||||
</Text>
|
||||
|
||||
<List spacing={8} size="sm">
|
||||
{participants.map((participant, participantIndex) => {
|
||||
const isSigned = participant.status === 'SIGNED';
|
||||
const isDeclined = participant.status === 'DECLINED';
|
||||
const annotationColor = getFileColor(participantIndex);
|
||||
|
||||
return (
|
||||
<List.Item key={participant.id} icon={getIcon(participant.status)}>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<div style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: annotationColor, flexShrink: 0 }} />
|
||||
<Text size="xs" truncate>
|
||||
{participant.name}
|
||||
</Text>
|
||||
</Group>
|
||||
{participant.email && participant.email !== participant.name && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
@{participant.email}
|
||||
</Text>
|
||||
)}
|
||||
<Badge size="xs" color={getColor(participant.status)} variant="light">
|
||||
{t(`certSign.collab.status.${participant.status.toLowerCase()}`, participant.status)}
|
||||
</Badge>
|
||||
</Stack>
|
||||
{!finalized && !isSigned && !isDeclined && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onRemove(participant.id)}
|
||||
title={t('certSign.collab.sessionDetail.removeParticipant', 'Remove')}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: '1rem' }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</List.Item>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Stack, Text, Button, Divider, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import type { SessionDetail } from '@app/types/signingSession';
|
||||
|
||||
interface SessionActionsPanelProps {
|
||||
session: SessionDetail;
|
||||
onAddParticipants: () => void;
|
||||
onFinalize: () => void;
|
||||
onLoadSignedPdf: () => void;
|
||||
finalizing: boolean;
|
||||
loadingPdf: boolean;
|
||||
}
|
||||
|
||||
export const SessionActionsPanel: React.FC<SessionActionsPanelProps> = ({
|
||||
session,
|
||||
onAddParticipants,
|
||||
onFinalize,
|
||||
onLoadSignedPdf,
|
||||
finalizing,
|
||||
loadingPdf,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allSigned = session.participants.every((p) => p.status === 'SIGNED');
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Session Info - only shown when there is something to display */}
|
||||
{(session.dueDate || session.message) && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.sessionDetail.sessionInfo', 'Session Info')}
|
||||
</Text>
|
||||
{session.dueDate && (
|
||||
<Paper p="xs" withBorder>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t('certSign.collab.sessionDetail.dueDate', 'Due Date')}
|
||||
</Text>
|
||||
<Text size="xs">{session.dueDate}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{session.message && (
|
||||
<Paper p="xs" withBorder>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t('certSign.collab.sessionDetail.messageLabel', 'Message')}
|
||||
</Text>
|
||||
<Text size="xs">{session.message}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Primary Actions */}
|
||||
{!session.finalized && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
leftSection={<AddIcon />}
|
||||
onClick={onAddParticipants}
|
||||
variant="light"
|
||||
fullWidth
|
||||
>
|
||||
{t('certSign.collab.sessionDetail.addParticipants', 'Add Participants')}
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
leftSection={<CheckCircleIcon />}
|
||||
color={allSigned ? 'green' : 'orange'}
|
||||
fullWidth
|
||||
onClick={onFinalize}
|
||||
loading={finalizing}
|
||||
>
|
||||
{allSigned
|
||||
? t('certSign.collab.finalize.button', 'Finalize and Load Signed PDF')
|
||||
: t('certSign.collab.finalize.early', 'Finalize with Current Signatures')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{session.finalized && (
|
||||
<>
|
||||
<Button
|
||||
leftSection={<CheckCircleIcon />}
|
||||
color="blue"
|
||||
fullWidth
|
||||
onClick={onLoadSignedPdf}
|
||||
loading={loadingPdf}
|
||||
>
|
||||
{t('certSign.collab.sessionDetail.loadSignedPdf', 'Load Signed PDF into Active Files')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Stack, Text, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { SignatureTypeSelector, SignatureType } from '@app/components/shared/wetSignature/SignatureTypeSelector';
|
||||
import { DrawSignatureCanvas } from '@app/components/shared/wetSignature/DrawSignatureCanvas';
|
||||
import { UploadSignatureImage } from '@app/components/shared/wetSignature/UploadSignatureImage';
|
||||
import { TypeSignatureText } from '@app/components/shared/wetSignature/TypeSignatureText';
|
||||
|
||||
export interface PlacedSignature {
|
||||
id: string;
|
||||
signature: string;
|
||||
type: SignatureType;
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AddSignaturesStepProps {
|
||||
onRequestPlacement: (signature: string, type: SignatureType) => void;
|
||||
onCancelPlacement?: () => void;
|
||||
placementMode: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AddSignaturesStep: React.FC<AddSignaturesStepProps> = ({
|
||||
onRequestPlacement,
|
||||
onCancelPlacement,
|
||||
placementMode,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Current signature being created
|
||||
const [signatureType, setSignatureType] = useState<SignatureType>('draw');
|
||||
const [signature, setSignature] = useState<string | null>(null);
|
||||
const [signatureText, setSignatureText] = useState('');
|
||||
const [fontFamily, setFontFamily] = useState('Arial');
|
||||
const [fontSize, setFontSize] = useState(40);
|
||||
const [textColor, setTextColor] = useState('#000000');
|
||||
|
||||
const hasSignature =
|
||||
(signatureType === 'draw' && signature) ||
|
||||
(signatureType === 'upload' && signature) ||
|
||||
(signatureType === 'type' && signatureText && signature);
|
||||
|
||||
const handlePlaceSignature = () => {
|
||||
if (signature) {
|
||||
onRequestPlacement(signature, signatureType);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Signature Creation */}
|
||||
<Paper p="md" withBorder>
|
||||
<Text size="sm" fw={600} mb="md">
|
||||
{t('certSign.collab.signRequest.steps.createSignature', 'Create Signature')}
|
||||
</Text>
|
||||
|
||||
<Stack gap="md">
|
||||
<SignatureTypeSelector
|
||||
value={signatureType}
|
||||
onChange={setSignatureType}
|
||||
disabled={disabled || placementMode}
|
||||
/>
|
||||
|
||||
{signatureType === 'draw' && (
|
||||
<DrawSignatureCanvas
|
||||
signature={signature}
|
||||
onChange={setSignature}
|
||||
disabled={disabled || placementMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signatureType === 'upload' && (
|
||||
<UploadSignatureImage
|
||||
signature={signature}
|
||||
onChange={setSignature}
|
||||
disabled={disabled || placementMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signatureType === 'type' && (
|
||||
<TypeSignatureText
|
||||
text={signatureText}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={fontSize}
|
||||
color={textColor}
|
||||
onTextChange={setSignatureText}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
onFontSizeChange={setFontSize}
|
||||
onColorChange={setTextColor}
|
||||
onSignatureChange={setSignature}
|
||||
disabled={disabled || placementMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!placementMode ? (
|
||||
<Button
|
||||
leftSection={<AddIcon />}
|
||||
onClick={handlePlaceSignature}
|
||||
disabled={!hasSignature || disabled}
|
||||
>
|
||||
{t('certSign.collab.signRequest.steps.placeOnPdf', 'Place on PDF')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
leftSection={<CancelIcon />}
|
||||
onClick={onCancelPlacement}
|
||||
disabled={disabled}
|
||||
variant="light"
|
||||
color="red"
|
||||
>
|
||||
{t('certSign.collab.signRequest.steps.cancelPlacement', 'Cancel Placement')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{placementMode && (
|
||||
<Text size="xs" c="blue" ta="center">
|
||||
{t('certSign.collab.signRequest.steps.clickMultipleTimes', 'Click on the PDF multiple times to place signatures. Drag any signature to move or resize it.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button, Stack, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector';
|
||||
|
||||
interface CertificateSelectionStepProps {
|
||||
certType: CertificateType;
|
||||
onCertTypeChange: (certType: CertificateType) => void;
|
||||
uploadFormat: UploadFormat;
|
||||
onUploadFormatChange: (format: UploadFormat) => void;
|
||||
p12File: File | null;
|
||||
onP12FileChange: (file: File | null) => void;
|
||||
privateKeyFile: File | null;
|
||||
onPrivateKeyFileChange: (file: File | null) => void;
|
||||
certFile: File | null;
|
||||
onCertFileChange: (file: File | null) => void;
|
||||
jksFile: File | null;
|
||||
onJksFileChange: (file: File | null) => void;
|
||||
password: string;
|
||||
onPasswordChange: (password: string) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CertificateSelectionStep: React.FC<CertificateSelectionStepProps> = ({
|
||||
certType,
|
||||
onCertTypeChange,
|
||||
uploadFormat,
|
||||
onUploadFormatChange,
|
||||
p12File,
|
||||
onP12FileChange,
|
||||
privateKeyFile,
|
||||
onPrivateKeyFileChange,
|
||||
certFile,
|
||||
onCertFileChange,
|
||||
jksFile,
|
||||
onJksFileChange,
|
||||
password,
|
||||
onPasswordChange,
|
||||
onBack,
|
||||
onNext,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Validation: if UPLOAD type, need file and password
|
||||
const isValid =
|
||||
certType === 'USER_CERT' ||
|
||||
certType === 'SERVER' ||
|
||||
(certType === 'UPLOAD' && p12File && password);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<CertificateSelector
|
||||
certType={certType}
|
||||
onCertTypeChange={onCertTypeChange}
|
||||
uploadFormat={uploadFormat}
|
||||
onUploadFormatChange={onUploadFormatChange}
|
||||
p12File={p12File}
|
||||
onP12FileChange={onP12FileChange}
|
||||
privateKeyFile={privateKeyFile}
|
||||
onPrivateKeyFileChange={onPrivateKeyFileChange}
|
||||
certFile={certFile}
|
||||
onCertFileChange={onCertFileChange}
|
||||
jksFile={jksFile}
|
||||
onJksFileChange={onJksFileChange}
|
||||
password={password}
|
||||
onPasswordChange={onPasswordChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('certSign.collab.signRequest.steps.back', 'Back')}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!isValid || disabled} style={{ flex: 1 }}>
|
||||
{t('certSign.collab.signRequest.steps.continueToPlacement', 'Continue to Placement')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Button, Stack, Text, Group, Divider, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DrawIcon from '@mui/icons-material/Draw';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector';
|
||||
import type { SignRequestDetail } from '@app/types/signingSession';
|
||||
|
||||
interface ReviewSignatureStepProps {
|
||||
signatureCount: number;
|
||||
certType: CertificateType;
|
||||
uploadFormat: UploadFormat;
|
||||
p12File: File | null;
|
||||
signRequest: SignRequestDetail;
|
||||
onBack: () => void;
|
||||
onSign: () => void;
|
||||
onDecline: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ReviewSignatureStep: React.FC<ReviewSignatureStepProps> = ({
|
||||
signatureCount,
|
||||
certType,
|
||||
uploadFormat,
|
||||
p12File,
|
||||
signRequest,
|
||||
onBack,
|
||||
onSign,
|
||||
onDecline,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getCertTypeName = () => {
|
||||
switch (certType) {
|
||||
case 'USER_CERT':
|
||||
return t('certSign.collab.signRequest.usePersonalCert', 'Personal Certificate');
|
||||
case 'SERVER':
|
||||
return t('certSign.collab.signRequest.useServerCert', 'Organization Certificate');
|
||||
case 'UPLOAD':
|
||||
return `${uploadFormat} — ${p12File?.name || t('certSign.collab.signRequest.uploadCert', 'Custom Certificate')}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('certSign.collab.signRequest.steps.reviewTitle', 'Review Before Signing')}
|
||||
</Text>
|
||||
|
||||
{/* Signatures Summary */}
|
||||
<div>
|
||||
<Group gap="xs" mb="xs">
|
||||
<DrawIcon sx={{ fontSize: 18 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.signRequest.steps.yourSignatures', 'Your Signatures ({{count}})', {
|
||||
count: signatureCount,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<Paper p="sm" withBorder>
|
||||
<Text size="sm">
|
||||
{signatureCount === 1
|
||||
? t('certSign.collab.signRequest.steps.oneSignature', '1 signature will be applied to the PDF')
|
||||
: t('certSign.collab.signRequest.steps.multipleSignatures', '{{count}} signatures will be applied to the PDF', {
|
||||
count: signatureCount,
|
||||
})}
|
||||
</Text>
|
||||
</Paper>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Certificate Info */}
|
||||
<div>
|
||||
<Group gap="xs" mb="xs">
|
||||
<SecurityIcon sx={{ fontSize: 18 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.signRequest.steps.certificate', 'Certificate')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm">{getCertTypeName()}</Text>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Settings from Owner */}
|
||||
<div>
|
||||
<Group gap="xs" mb="xs">
|
||||
<SettingsIcon sx={{ fontSize: 18 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{t('certSign.collab.signRequest.signatureSettings', 'Signature Settings')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Paper p="sm" withBorder>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t('certSign.collab.signRequest.signatureInfo', 'These settings are configured by the document owner')}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
<strong>{t('certSign.collab.signRequest.steps.visibility', 'Visibility:')}</strong>{' '}
|
||||
{signRequest.showSignature
|
||||
? t('certSign.collab.signRequest.steps.visible', 'Visible')
|
||||
: t('certSign.collab.signRequest.steps.invisible', 'Invisible')}
|
||||
</Text>
|
||||
{signRequest.reason && (
|
||||
<Text size="sm">
|
||||
<strong>{t('certSign.collab.signRequest.steps.reason', 'Reason:')}</strong> {signRequest.reason}
|
||||
</Text>
|
||||
)}
|
||||
{signRequest.location && (
|
||||
<Text size="sm">
|
||||
<strong>{t('certSign.collab.signRequest.steps.location', 'Location:')}</strong>{' '}
|
||||
{signRequest.location}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Group gap="sm" mt="md">
|
||||
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('certSign.collab.signRequest.steps.back', 'Back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={onDecline}
|
||||
disabled={disabled}
|
||||
leftSection={<CancelIcon sx={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('certSign.collab.signRequest.declineButton', 'Decline')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSign}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
color="green"
|
||||
leftSection={<CheckCircleIcon sx={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('certSign.collab.signRequest.signButton', 'Sign Document')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SignatureTypeSelector, SignatureType } from '@app/components/shared/wetSignature/SignatureTypeSelector';
|
||||
import { DrawSignatureCanvas } from '@app/components/shared/wetSignature/DrawSignatureCanvas';
|
||||
import { UploadSignatureImage } from '@app/components/shared/wetSignature/UploadSignatureImage';
|
||||
import { TypeSignatureText } from '@app/components/shared/wetSignature/TypeSignatureText';
|
||||
|
||||
interface SignatureCreationStepProps {
|
||||
signatureType: SignatureType;
|
||||
onSignatureTypeChange: (type: SignatureType) => void;
|
||||
signature: string | null;
|
||||
onSignatureChange: (signature: string | null) => void;
|
||||
// For type signature
|
||||
signatureText: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
textColor: string;
|
||||
onSignatureTextChange: (text: string) => void;
|
||||
onFontFamilyChange: (font: string) => void;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
onTextColorChange: (color: string) => void;
|
||||
onNext: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SignatureCreationStep: React.FC<SignatureCreationStepProps> = ({
|
||||
signatureType,
|
||||
onSignatureTypeChange,
|
||||
signature,
|
||||
onSignatureChange,
|
||||
signatureText,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
textColor,
|
||||
onSignatureTextChange,
|
||||
onFontFamilyChange,
|
||||
onFontSizeChange,
|
||||
onTextColorChange,
|
||||
onNext,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasSignature =
|
||||
(signatureType === 'draw' && signature) ||
|
||||
(signatureType === 'upload' && signature) ||
|
||||
(signatureType === 'type' && signatureText && signature);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SignatureTypeSelector
|
||||
value={signatureType}
|
||||
onChange={onSignatureTypeChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{signatureType === 'draw' && (
|
||||
<DrawSignatureCanvas
|
||||
signature={signature}
|
||||
onChange={onSignatureChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signatureType === 'upload' && (
|
||||
<UploadSignatureImage
|
||||
signature={signature}
|
||||
onChange={onSignatureChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signatureType === 'type' && (
|
||||
<TypeSignatureText
|
||||
text={signatureText}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={fontSize}
|
||||
color={textColor}
|
||||
onTextChange={onSignatureTextChange}
|
||||
onFontFamilyChange={onFontFamilyChange}
|
||||
onFontSizeChange={onFontSizeChange}
|
||||
onColorChange={onTextColorChange}
|
||||
onSignatureChange={onSignatureChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={onNext} disabled={!hasSignature || disabled} fullWidth>
|
||||
{t('certSign.collab.signRequest.steps.continue', 'Continue to Certificate Selection')}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button, Stack, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
interface SignaturePlacementStepProps {
|
||||
isPlaced: boolean;
|
||||
placementInfo: { page: number; x: number; y: number } | null;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode; // PDF viewer with placement capability
|
||||
}
|
||||
|
||||
export const SignaturePlacementStep: React.FC<SignaturePlacementStepProps> = ({
|
||||
isPlaced,
|
||||
placementInfo,
|
||||
onBack,
|
||||
onNext,
|
||||
disabled = false,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{isPlaced
|
||||
? t(
|
||||
'certSign.collab.signRequest.steps.signaturePlaced',
|
||||
'Signature placed on page {{page}}. You can adjust the position by clicking again or continue to review.',
|
||||
{ page: placementInfo?.page || 1 }
|
||||
)
|
||||
: t(
|
||||
'certSign.collab.signRequest.steps.clickToPlace',
|
||||
'Click on the PDF where you would like your signature to appear.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* PDF Viewer (passed as children) */}
|
||||
<div style={{ flex: 1, minHeight: '400px' }}>{children}</div>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
|
||||
{t('certSign.collab.signRequest.steps.back', 'Back')}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!isPlaced || disabled} style={{ flex: 1 }}>
|
||||
{t('certSign.collab.signRequest.steps.continueToReview', 'Continue to Review')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useCertificateChoiceTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('certificateChoice.tooltip.header', 'Certificate Types'),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('certificateChoice.tooltip.personal.title', 'Personal Certificate'),
|
||||
description: t(
|
||||
'certificateChoice.tooltip.personal.description',
|
||||
'An auto-generated certificate unique to your user account. Suitable for individual signatures.'
|
||||
),
|
||||
bullets: [
|
||||
t('certificateChoice.tooltip.personal.bullet1', 'Generated automatically on first use'),
|
||||
t('certificateChoice.tooltip.personal.bullet2', 'Tied to your user account'),
|
||||
t('certificateChoice.tooltip.personal.bullet3', 'Cannot be shared with other users'),
|
||||
t('certificateChoice.tooltip.personal.bullet4', 'Best for: Personal documents, individual accountability'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('certificateChoice.tooltip.organization.title', 'Organization Certificate'),
|
||||
description: t(
|
||||
'certificateChoice.tooltip.organization.description',
|
||||
'A shared certificate provided by your organization. Used for company-wide signing authority.'
|
||||
),
|
||||
bullets: [
|
||||
t('certificateChoice.tooltip.organization.bullet1', 'Managed by system administrators'),
|
||||
t('certificateChoice.tooltip.organization.bullet2', 'Shared across authorized users'),
|
||||
t('certificateChoice.tooltip.organization.bullet3', 'Represents company identity, not individual'),
|
||||
t('certificateChoice.tooltip.organization.bullet4', 'Best for: Official documents, team signatures'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('certificateChoice.tooltip.upload.title', 'Upload Custom P12'),
|
||||
description: t(
|
||||
'certificateChoice.tooltip.upload.description',
|
||||
'Use your own PKCS#12 certificate file. Provides full control over certificate properties.'
|
||||
),
|
||||
bullets: [
|
||||
t('certificateChoice.tooltip.upload.bullet1', 'Requires P12/PFX file and password'),
|
||||
t('certificateChoice.tooltip.upload.bullet2', 'Can be issued by external Certificate Authorities'),
|
||||
t('certificateChoice.tooltip.upload.bullet3', 'Higher trust level for legal documents'),
|
||||
t('certificateChoice.tooltip.upload.bullet4', 'Best for: Legally binding contracts, external validation'),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useGroupSigningTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('groupSigning.tooltip.header', 'About Group Signing'),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('groupSigning.tooltip.sequential.title', 'Sequential Signing'),
|
||||
description: t(
|
||||
'groupSigning.tooltip.sequential.description',
|
||||
'Participants sign documents in the order you specify. Each signer receives a notification when it is their turn.'
|
||||
),
|
||||
bullets: [
|
||||
t('groupSigning.tooltip.sequential.bullet1', 'First participant must sign before the second can access the document'),
|
||||
t('groupSigning.tooltip.sequential.bullet2', 'Ensures proper signing order for legal compliance'),
|
||||
t('groupSigning.tooltip.sequential.bullet3', 'You can reorder participants by dragging them in the list'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('groupSigning.tooltip.roles.title', 'Participant Roles'),
|
||||
description: t(
|
||||
'groupSigning.tooltip.roles.description',
|
||||
'You control the signature appearance settings for all participants.'
|
||||
),
|
||||
bullets: [
|
||||
t('groupSigning.tooltip.roles.bullet1', 'Owner (you): Creates session, configures signature defaults, finalizes document'),
|
||||
t('groupSigning.tooltip.roles.bullet2', 'Participants: Create their signature, choose certificate, place on PDF'),
|
||||
t('groupSigning.tooltip.roles.bullet3', 'Participants cannot modify signature visibility, reason, or location settings'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('groupSigning.tooltip.finalization.title', 'Finalization Process'),
|
||||
description: t(
|
||||
'groupSigning.tooltip.finalization.description',
|
||||
'Once all participants have signed (or you choose to finalize early), you can generate the final signed PDF.'
|
||||
),
|
||||
bullets: [
|
||||
t('groupSigning.tooltip.finalization.bullet1', 'All signatures are applied in the participant order you specified'),
|
||||
t('groupSigning.tooltip.finalization.bullet2', 'You can finalize with partial signatures if needed'),
|
||||
t('groupSigning.tooltip.finalization.bullet3', 'Once finalized, the session cannot be modified'),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useSessionManagementTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('sessionManagement.tooltip.header', 'Managing Signing Sessions'),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('sessionManagement.tooltip.addParticipants.title', 'Adding Participants'),
|
||||
description: t(
|
||||
'sessionManagement.tooltip.addParticipants.description',
|
||||
'You can add more participants to an active session at any time before finalization.'
|
||||
),
|
||||
bullets: [
|
||||
t('sessionManagement.tooltip.addParticipants.bullet1', 'New participants added to the end of signing order'),
|
||||
t('sessionManagement.tooltip.addParticipants.bullet2', 'Cannot add participants after session is finalized'),
|
||||
t('sessionManagement.tooltip.addParticipants.bullet3', 'Each participant receives a notification when it\'s their turn'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sessionManagement.tooltip.finalization.title', 'Session Finalization'),
|
||||
description: t(
|
||||
'sessionManagement.tooltip.finalization.description',
|
||||
'Finalization combines all signatures into a single signed PDF. This action cannot be undone.'
|
||||
),
|
||||
bullets: [
|
||||
t('sessionManagement.tooltip.finalization.bullet1', '<b>Full finalization</b>: All participants have signed'),
|
||||
t('sessionManagement.tooltip.finalization.bullet2', '<b>Partial finalization</b>: Some participants haven\'t signed yet'),
|
||||
t('sessionManagement.tooltip.finalization.bullet3', 'Unsigned participants will be excluded from the final document'),
|
||||
t('sessionManagement.tooltip.finalization.bullet4', 'Once finalized, you can load the signed PDF into active files'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sessionManagement.tooltip.signatureOrder.title', 'Signature Order'),
|
||||
description: t(
|
||||
'sessionManagement.tooltip.signatureOrder.description',
|
||||
'The order you specify when creating the session determines who signs first.'
|
||||
),
|
||||
bullets: [
|
||||
t('sessionManagement.tooltip.signatureOrder.bullet1', 'Each signature is applied sequentially to the PDF'),
|
||||
t('sessionManagement.tooltip.signatureOrder.bullet2', 'Later signers can see earlier signatures'),
|
||||
t('sessionManagement.tooltip.signatureOrder.bullet3', 'Critical for approval workflows and legal chains of custody'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sessionManagement.tooltip.participantRemoval.title', 'Removing Participants'),
|
||||
description: t(
|
||||
'sessionManagement.tooltip.participantRemoval.description',
|
||||
'Participants can be removed from sessions before they sign.'
|
||||
),
|
||||
bullets: [
|
||||
t('sessionManagement.tooltip.participantRemoval.bullet1', 'Cannot remove participants who have already signed'),
|
||||
t('sessionManagement.tooltip.participantRemoval.bullet2', 'Removed participants no longer receive notifications'),
|
||||
t('sessionManagement.tooltip.participantRemoval.bullet3', 'Signing order adjusts automatically'),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useSignatureSettingsTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('signatureSettings.tooltip.header', 'Signature Appearance Settings'),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('signatureSettings.tooltip.visibility.title', 'Signature Visibility'),
|
||||
description: t(
|
||||
'signatureSettings.tooltip.visibility.description',
|
||||
'Controls whether the signature is visible on the document or embedded invisibly.'
|
||||
),
|
||||
bullets: [
|
||||
t('signatureSettings.tooltip.visibility.bullet1', '<b>Visible</b>: Signature appears on PDF with custom appearance'),
|
||||
t('signatureSettings.tooltip.visibility.bullet2', '<b>Invisible</b>: Certificate embedded without visual mark'),
|
||||
t('signatureSettings.tooltip.visibility.bullet3', 'Invisible signatures still provide cryptographic validation'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('signatureSettings.tooltip.reason.title', 'Signature Reason'),
|
||||
description: t(
|
||||
'signatureSettings.tooltip.reason.description',
|
||||
'Optional text explaining why the document is being signed. Stored in certificate metadata.'
|
||||
),
|
||||
bullets: [
|
||||
t('signatureSettings.tooltip.reason.bullet1', 'Examples: "Approval", "Contract Agreement", "Review Complete"'),
|
||||
t('signatureSettings.tooltip.reason.bullet2', 'Visible in PDF signature properties'),
|
||||
t('signatureSettings.tooltip.reason.bullet3', 'Useful for audit trails and compliance'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('signatureSettings.tooltip.location.title', 'Signature Location'),
|
||||
description: t(
|
||||
'signatureSettings.tooltip.location.description',
|
||||
'Optional geographic location where the signature was applied. Stored in certificate metadata.'
|
||||
),
|
||||
bullets: [
|
||||
t('signatureSettings.tooltip.location.bullet1', 'Examples: "New York, USA", "London Office", "Remote"'),
|
||||
t('signatureSettings.tooltip.location.bullet2', 'Not the same as page position'),
|
||||
t('signatureSettings.tooltip.location.bullet3', 'May be required for certain legal jurisdictions'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('signatureSettings.tooltip.logo.title', 'Company Logo'),
|
||||
description: t(
|
||||
'signatureSettings.tooltip.logo.description',
|
||||
'Add a company logo to visible signatures for branding and authenticity.'
|
||||
),
|
||||
bullets: [
|
||||
t('signatureSettings.tooltip.logo.bullet1', 'Displayed alongside signature and text'),
|
||||
t('signatureSettings.tooltip.logo.bullet2', 'Supports PNG, JPG formats'),
|
||||
t('signatureSettings.tooltip.logo.bullet3', 'Enhances professional appearance'),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useWetSignatureTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('wetSignature.tooltip.header', 'Signature Creation Methods'),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('wetSignature.tooltip.draw.title', 'Draw Signature'),
|
||||
description: t(
|
||||
'wetSignature.tooltip.draw.description',
|
||||
'Create a handwritten signature using your mouse or touchscreen. Best for personal, authentic signatures.'
|
||||
),
|
||||
bullets: [
|
||||
t('wetSignature.tooltip.draw.bullet1', 'Customize pen color and thickness'),
|
||||
t('wetSignature.tooltip.draw.bullet2', 'Clear and redraw until satisfied'),
|
||||
t('wetSignature.tooltip.draw.bullet3', 'Works on touch devices (tablets, phones)'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('wetSignature.tooltip.upload.title', 'Upload Signature Image'),
|
||||
description: t(
|
||||
'wetSignature.tooltip.upload.description',
|
||||
'Upload a pre-created signature image. Ideal if you have a scanned signature or company logo.'
|
||||
),
|
||||
bullets: [
|
||||
t('wetSignature.tooltip.upload.bullet1', 'Supports PNG, JPG, and other image formats'),
|
||||
t('wetSignature.tooltip.upload.bullet2', 'Transparent backgrounds recommended for best results'),
|
||||
t('wetSignature.tooltip.upload.bullet3', 'Image will be resized to fit signature area'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('wetSignature.tooltip.type.title', 'Type Signature'),
|
||||
description: t(
|
||||
'wetSignature.tooltip.type.description',
|
||||
'Generate a signature from typed text. Fast and consistent, suitable for business documents.'
|
||||
),
|
||||
bullets: [
|
||||
t('wetSignature.tooltip.type.bullet1', 'Choose from multiple fonts'),
|
||||
t('wetSignature.tooltip.type.bullet2', 'Customize text size and color'),
|
||||
t('wetSignature.tooltip.type.bullet3', 'Perfect for standardized signatures'),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -15,11 +15,8 @@ export function DocumentReadyWrapper({ children, fallback = null }: DocumentRead
|
||||
|
||||
const checkActiveDocument = async () => {
|
||||
await ready;
|
||||
|
||||
// Try to get the active document from the plugin's provides()
|
||||
const docManagerApi = plugin.provides?.();
|
||||
if (docManagerApi) {
|
||||
// Try different methods to get the active document
|
||||
const activeDoc = docManagerApi.getActiveDocument?.();
|
||||
if (activeDoc?.id) {
|
||||
setActiveDocumentId(activeDoc.id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState, useImperativeHandle, forwardRef, useRef } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import type { PluginRegistry } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
@@ -9,8 +9,8 @@ import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react
|
||||
import { Scroller, ScrollPluginPackage } from '@embedpdf/plugin-scroll/react';
|
||||
import { DocumentManagerPluginPackage } from '@embedpdf/plugin-document-manager/react';
|
||||
import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
|
||||
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
|
||||
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
|
||||
import { ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react';
|
||||
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider, useInteractionManagerCapability } from '@embedpdf/plugin-interaction-manager/react';
|
||||
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
|
||||
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
|
||||
import { PanPluginPackage } from '@embedpdf/plugin-pan/react';
|
||||
@@ -19,17 +19,16 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { Rotation, PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import type { PdfAnnotationObject } from '@embedpdf/models';
|
||||
import type { AnnotationEvent } from '@embedpdf/plugin-annotation';
|
||||
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||
|
||||
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
|
||||
import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback';
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
import { ActionIcon, Center, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { ScrollAPIBridge } from '@app/components/viewer/ScrollAPIBridge';
|
||||
import { SelectionAPIBridge } from '@app/components/viewer/SelectionAPIBridge';
|
||||
import { PanAPIBridge } from '@app/components/viewer/PanAPIBridge';
|
||||
@@ -38,21 +37,139 @@ import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge';
|
||||
import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
|
||||
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
|
||||
import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrapper';
|
||||
import {
|
||||
Z_INDEX_SIGNATURE_OVERLAY,
|
||||
Z_INDEX_SIGNATURE_OVERLAY_DELETE,
|
||||
Z_INDEX_SIGNATURE_OVERLAY_HANDLE,
|
||||
} from '@app/styles/zIndex';
|
||||
|
||||
/** Rendered inside EmbedPDF context; exposes interaction manager pause/resume via ref. */
|
||||
function InteractionPauseBridge({ bridgeRef }: {
|
||||
bridgeRef: React.MutableRefObject<{ pause: () => void; resume: () => void } | null>;
|
||||
}) {
|
||||
const { provides } = useInteractionManagerCapability();
|
||||
useEffect(() => {
|
||||
if (provides) {
|
||||
bridgeRef.current = { pause: () => provides.pause(), resume: () => provides.resume() };
|
||||
}
|
||||
return () => { bridgeRef.current = null; };
|
||||
}, [provides, bridgeRef]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const DOCUMENT_NAME = 'stirling-pdf-signing-viewer';
|
||||
|
||||
export interface SignaturePreview {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
signatureData: string; // Base64 PNG image
|
||||
signatureType: 'canvas' | 'image' | 'text';
|
||||
color?: string; // Per-participant color (rgb(...) string); falls back to default blue
|
||||
participantName?: string; // Shown in tooltip on hover
|
||||
}
|
||||
|
||||
interface LocalEmbedPDFWithAnnotationsProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
onAnnotationChange?: (annotations: PdfAnnotationObject[]) => void;
|
||||
onAnnotationChange?: (annotations: SignaturePreview[]) => void;
|
||||
placementMode?: boolean;
|
||||
signatureData?: string;
|
||||
signatureType?: 'canvas' | 'image' | 'text';
|
||||
onPlaceSignature?: (id: string, pageIndex: number, x: number, y: number, width: number, height: number) => void;
|
||||
onPreviewCountChange?: (count: number) => void;
|
||||
initialSignatures?: SignaturePreview[]; // Initial signatures to display (read-only preview)
|
||||
readOnly?: boolean; // If true, signature previews cannot be moved or deleted
|
||||
}
|
||||
|
||||
export function LocalEmbedPDFWithAnnotations({
|
||||
export interface AnnotationAPI {
|
||||
setActiveTool: (toolId: string | null) => void;
|
||||
setToolDefaults: (toolId: string, defaults: any) => void;
|
||||
getActiveTool: () => any;
|
||||
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
||||
getAllAnnotations: () => Promise<any[]>;
|
||||
getSignaturePreviews: () => SignaturePreview[];
|
||||
clearPreviews: () => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
resetZoom: () => void;
|
||||
}
|
||||
|
||||
export const LocalEmbedPDFWithAnnotations = forwardRef<AnnotationAPI | null, LocalEmbedPDFWithAnnotationsProps>(({
|
||||
file,
|
||||
url,
|
||||
onAnnotationChange
|
||||
}: LocalEmbedPDFWithAnnotationsProps) {
|
||||
onAnnotationChange,
|
||||
placementMode = false,
|
||||
signatureData,
|
||||
signatureType,
|
||||
onPlaceSignature,
|
||||
onPreviewCountChange,
|
||||
initialSignatures = [],
|
||||
readOnly = false
|
||||
}, ref) => {
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const annotationApiRef = useRef<any>(null);
|
||||
const zoomApiRef = useRef<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State for signature preview overlays (support multiple)
|
||||
const [signaturePreviews, setSignaturePreviews] = useState<SignaturePreview[]>(initialSignatures);
|
||||
|
||||
// Track if a drag operation just occurred to prevent click from firing
|
||||
const isDraggingRef = useRef(false);
|
||||
const interactionPauseRef = useRef<{ pause: () => void; resume: () => void } | null>(null);
|
||||
|
||||
// Track cursor position over a specific page for hover preview
|
||||
const [cursorOnPage, setCursorOnPage] = useState<{ pageIndex: number; x: number; y: number } | null>(null);
|
||||
|
||||
// Expose annotation API to parent
|
||||
useImperativeHandle(ref, () => ({
|
||||
setActiveTool: (toolId: string | null) => {
|
||||
annotationApiRef.current?.setActiveTool(toolId);
|
||||
},
|
||||
setToolDefaults: (toolId: string, defaults: any) => {
|
||||
annotationApiRef.current?.setToolDefaults(toolId, defaults);
|
||||
},
|
||||
getActiveTool: () => {
|
||||
return annotationApiRef.current?.getActiveTool();
|
||||
},
|
||||
getPageAnnotations: async (pageIndex: number) => {
|
||||
if (!annotationApiRef.current?.getPageAnnotations) return [];
|
||||
const task = annotationApiRef.current.getPageAnnotations({ pageIndex });
|
||||
if (task?.toPromise) {
|
||||
return await task.toPromise();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
getAllAnnotations: async () => {
|
||||
// Get all annotations across all pages
|
||||
// Note: In practice, we'll use getPageAnnotations for the specific page
|
||||
// where the user placed their signature, so this method is optional
|
||||
if (!annotationApiRef.current?.getPageAnnotations) return [];
|
||||
|
||||
// Would need document page count to iterate through all pages
|
||||
// For signing workflow, we track annotations via onAnnotationChange callback instead
|
||||
return [];
|
||||
},
|
||||
getSignaturePreviews: () => {
|
||||
return signaturePreviews;
|
||||
},
|
||||
clearPreviews: () => {
|
||||
setSignaturePreviews([]);
|
||||
},
|
||||
zoomIn: () => {
|
||||
zoomApiRef.current?.zoomIn();
|
||||
},
|
||||
zoomOut: () => {
|
||||
zoomApiRef.current?.zoomOut();
|
||||
},
|
||||
resetZoom: () => {
|
||||
zoomApiRef.current?.resetZoom();
|
||||
},
|
||||
}), [signaturePreviews]);
|
||||
|
||||
// Convert File to URL if needed
|
||||
useEffect(() => {
|
||||
@@ -65,6 +182,16 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
}
|
||||
}, [file, url]);
|
||||
|
||||
// Notify parent when signature previews change
|
||||
useEffect(() => {
|
||||
if (onAnnotationChange) {
|
||||
onAnnotationChange(signaturePreviews);
|
||||
}
|
||||
if (onPreviewCountChange) {
|
||||
onPreviewCountChange(signaturePreviews.length);
|
||||
}
|
||||
}, [signaturePreviews, onAnnotationChange, onPreviewCountChange]);
|
||||
|
||||
// Create plugins configuration with annotation support
|
||||
const plugins = useMemo(() => {
|
||||
if (!pdfUrl) return [];
|
||||
@@ -113,7 +240,7 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
|
||||
// Register zoom plugin
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
defaultZoomLevel: 1.4,
|
||||
defaultZoomLevel: ZoomMode.FitWidth,
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3.0,
|
||||
}),
|
||||
@@ -178,7 +305,7 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
<div ref={containerRef} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
@@ -188,6 +315,7 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
minWidth: 0
|
||||
}}>
|
||||
<EmbedPDF
|
||||
key={pdfUrl}
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={async (registry: PluginRegistry) => {
|
||||
@@ -198,15 +326,21 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
const annotationApi = annotationPlugin.provides();
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Add custom signature stamp tool
|
||||
// Store reference for parent component access
|
||||
annotationApiRef.current = annotationApi;
|
||||
|
||||
// Add custom signature image tool
|
||||
// Using FreeText with appearance for better image support
|
||||
annotationApi.addTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
interaction: { exclusive: false, cursor: 'crosshair' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
// Will be set dynamically when user creates signature
|
||||
// Image data will be set dynamically via setToolDefaults
|
||||
width: 150,
|
||||
height: 75,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,17 +358,21 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for annotation events to notify parent
|
||||
if (onAnnotationChange) {
|
||||
annotationApi.onAnnotationEvent((event: AnnotationEvent) => {
|
||||
if (event.type !== 'loaded' && event.committed) {
|
||||
onAnnotationChange([event.annotation]);
|
||||
}
|
||||
});
|
||||
// Wire zoom API so parent can call zoomIn/zoomOut/resetZoom
|
||||
const zoomPlugin = registry.getPlugin('zoom');
|
||||
if (zoomPlugin?.provides) {
|
||||
const zoomApi = zoomPlugin.provides();
|
||||
zoomApiRef.current = {
|
||||
zoomIn: () => zoomApi.zoomIn?.(),
|
||||
zoomOut: () => zoomApi.zoomOut?.(),
|
||||
resetZoom: () => zoomApi.requestZoom?.(ZoomMode.FitWidth, { vx: 0.5, vy: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}}
|
||||
>
|
||||
<ZoomAPIBridge />
|
||||
<InteractionPauseBridge bridgeRef={interactionPauseRef} />
|
||||
<ScrollAPIBridge />
|
||||
<SelectionAPIBridge />
|
||||
<PanAPIBridge />
|
||||
@@ -254,7 +392,7 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
<Viewport
|
||||
documentId={documentId}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
backgroundColor: 'var(--bg-background)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
@@ -280,12 +418,50 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
msUserSelect: 'none',
|
||||
cursor: placementMode ? 'crosshair' : 'default',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onMouseMove={(e) => {
|
||||
if (!placementMode || !signatureData) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setCursorOnPage({ pageIndex, x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setCursorOnPage(prev => prev?.pageIndex === pageIndex ? null : prev);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (isDraggingRef.current) return;
|
||||
|
||||
if (placementMode && onPlaceSignature) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
// Store as fractions (0–1) of the rendered page so overlays
|
||||
// remain correct at any zoom level (scale not in new API)
|
||||
const sigWidth = 150 / width;
|
||||
const sigHeight = 75 / height;
|
||||
const rawX = (e.clientX - rect.left) / width;
|
||||
const rawY = (e.clientY - rect.top) / height;
|
||||
const x = Math.max(0, Math.min(rawX - sigWidth / 2, 1 - sigWidth));
|
||||
const y = Math.max(0, Math.min(rawY - sigHeight / 2, 1 - sigHeight));
|
||||
|
||||
const newPreview = {
|
||||
id: `sig-preview-${Date.now()}-${Math.random()}`,
|
||||
pageIndex,
|
||||
x,
|
||||
y,
|
||||
width: sigWidth,
|
||||
height: sigHeight,
|
||||
signatureData: signatureData || '',
|
||||
signatureType: signatureType || 'image',
|
||||
};
|
||||
setSignaturePreviews(prev => [...prev, newPreview]);
|
||||
onPlaceSignature(newPreview.id, pageIndex, x * width, y * height, sigWidth * width, sigHeight * height);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TilingLayer documentId={documentId} pageIndex={pageIndex} />
|
||||
|
||||
@@ -293,11 +469,239 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
|
||||
<SelectionLayer documentId={documentId} pageIndex={pageIndex} />
|
||||
|
||||
{/* Annotation layer for signatures */}
|
||||
<AnnotationLayer
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
selectionOutline={{ color: "#007ACC" }}
|
||||
/>
|
||||
|
||||
{/* Signature preview overlays (support multiple) */}
|
||||
{signaturePreviews
|
||||
.filter(preview => preview.pageIndex === pageIndex)
|
||||
.map((preview) => {
|
||||
if (!preview.signatureData) return null;
|
||||
const color = preview.color ?? 'rgb(0, 122, 204)';
|
||||
const colorOpacity = (opacity: number) =>
|
||||
color.startsWith('rgb(')
|
||||
? color.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`)
|
||||
: color;
|
||||
return (
|
||||
<Tooltip
|
||||
key={preview.id}
|
||||
label={preview.participantName ?? ''}
|
||||
position="top"
|
||||
withArrow
|
||||
disabled={!preview.participantName}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: preview.x * width,
|
||||
top: preview.y * height,
|
||||
width: preview.width * width,
|
||||
height: preview.height * height,
|
||||
border: readOnly ? `1px dashed ${colorOpacity(0.4)}` : `2px solid ${color}`,
|
||||
boxShadow: readOnly ? 'none' : `0 0 10px ${colorOpacity(0.5)}`,
|
||||
cursor: readOnly ? 'default' : 'move',
|
||||
zIndex: Z_INDEX_SIGNATURE_OVERLAY,
|
||||
backgroundColor: readOnly ? 'transparent' : 'rgba(255, 255, 255, 0.1)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Delete button - only show when not read-only */}
|
||||
{!readOnly && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
color="red"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: -10,
|
||||
zIndex: Z_INDEX_SIGNATURE_OVERLAY_DELETE,
|
||||
pointerEvents: 'auto',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
||||
border: '2px solid white',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSignaturePreviews(prev => prev.filter(p => p.id !== preview.id));
|
||||
}}
|
||||
aria-label="Delete signature"
|
||||
>
|
||||
<CloseIcon style={{ fontSize: '0.8rem' }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: readOnly ? 'none' : 'auto',
|
||||
}}
|
||||
onPointerDown={readOnly ? undefined : (e) => {
|
||||
if ((e.target as HTMLElement).dataset.resizeHandle) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
interactionPauseRef.current?.pause();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startLeft = preview.x;
|
||||
const startTop = preview.y;
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
isDraggingRef.current = true;
|
||||
const deltaX = (moveEvent.clientX - startX) / width;
|
||||
const deltaY = (moveEvent.clientY - startY) / height;
|
||||
setSignaturePreviews(prev => prev.map(p =>
|
||||
p.id === preview.id
|
||||
? { ...p, x: startLeft + deltaX, y: startTop + deltaY }
|
||||
: p
|
||||
));
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
el.removeEventListener('pointermove', handlePointerMove);
|
||||
el.removeEventListener('pointerup', handlePointerUp);
|
||||
el.releasePointerCapture(upEvent.pointerId);
|
||||
interactionPauseRef.current?.resume();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setTimeout(() => { isDraggingRef.current = false; }, 10);
|
||||
};
|
||||
|
||||
el.addEventListener('pointermove', handlePointerMove);
|
||||
el.addEventListener('pointerup', handlePointerUp);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={preview.signatureData}
|
||||
alt="Signature preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Resize handles */}
|
||||
{[
|
||||
{ position: 'nw', cursor: 'nw-resize', top: -4, left: -4 },
|
||||
{ position: 'ne', cursor: 'ne-resize', top: -4, right: -4 },
|
||||
{ position: 'sw', cursor: 'sw-resize', bottom: -4, left: -4 },
|
||||
{ position: 'se', cursor: 'se-resize', bottom: -4, right: -4 },
|
||||
].map((handle) => (
|
||||
<div
|
||||
key={handle.position}
|
||||
data-resize-handle="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 8,
|
||||
height: 8,
|
||||
backgroundColor: color,
|
||||
border: '1px solid white',
|
||||
cursor: handle.cursor,
|
||||
zIndex: Z_INDEX_SIGNATURE_OVERLAY_HANDLE,
|
||||
...(handle.top !== undefined && { top: handle.top }),
|
||||
...(handle.bottom !== undefined && { bottom: handle.bottom }),
|
||||
...(handle.left !== undefined && { left: handle.left }),
|
||||
...(handle.right !== undefined && { right: handle.right }),
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
interactionPauseRef.current?.pause();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startWidth = preview.width;
|
||||
const startHeight = preview.height;
|
||||
const startLeft = preview.x;
|
||||
const startTop = preview.y;
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
isDraggingRef.current = true;
|
||||
const deltaX = (moveEvent.clientX - startX) / width;
|
||||
const deltaY = (moveEvent.clientY - startY) / height;
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
let newX = startLeft;
|
||||
let newY = startTop;
|
||||
|
||||
// Min sizes as fractions: 50px / pageWidth, 25px / pageHeight
|
||||
const minW = 50 / width;
|
||||
const minH = 25 / height;
|
||||
|
||||
if (handle.position.includes('e')) {
|
||||
newWidth = Math.max(minW, startWidth + deltaX);
|
||||
}
|
||||
if (handle.position.includes('w')) {
|
||||
newWidth = Math.max(minW, startWidth - deltaX);
|
||||
newX = startLeft + (startWidth - newWidth);
|
||||
}
|
||||
if (handle.position.includes('s')) {
|
||||
newHeight = Math.max(minH, startHeight + deltaY);
|
||||
}
|
||||
if (handle.position.includes('n')) {
|
||||
newHeight = Math.max(minH, startHeight - deltaY);
|
||||
newY = startTop + (startHeight - newHeight);
|
||||
}
|
||||
|
||||
setSignaturePreviews(prev => prev.map(p =>
|
||||
p.id === preview.id
|
||||
? { ...p, x: newX, y: newY, width: newWidth, height: newHeight }
|
||||
: p
|
||||
));
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
el.removeEventListener('pointermove', handlePointerMove);
|
||||
el.removeEventListener('pointerup', handlePointerUp);
|
||||
el.releasePointerCapture(upEvent.pointerId);
|
||||
interactionPauseRef.current?.resume();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setTimeout(() => { isDraggingRef.current = false; }, 10);
|
||||
};
|
||||
|
||||
el.addEventListener('pointermove', handlePointerMove);
|
||||
el.addEventListener('pointerup', handlePointerUp);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover preview: ghost signature following cursor in placement mode */}
|
||||
{placementMode && signatureData && cursorOnPage?.pageIndex === pageIndex && (
|
||||
<img
|
||||
src={signatureData}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: Math.max(0, Math.min(cursorOnPage.x - 75, width - 150)),
|
||||
top: Math.max(0, Math.min(cursorOnPage.y - 37.5, height - 75)),
|
||||
width: 150,
|
||||
height: 75,
|
||||
opacity: 0.6,
|
||||
pointerEvents: 'none',
|
||||
objectFit: 'contain',
|
||||
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
|
||||
borderRadius: '4px',
|
||||
zIndex: Z_INDEX_SIGNATURE_OVERLAY + 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
@@ -310,4 +714,6 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
</EmbedPDF>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
LocalEmbedPDFWithAnnotations.displayName = 'LocalEmbedPDFWithAnnotations';
|
||||
|
||||
Reference in New Issue
Block a user