Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2026-03-25 11:00:40 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh EthanHealy01 Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 47cad0a131
commit 28613caf8a
181 changed files with 25715 additions and 124 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
export interface AuthContextType {
session: null;
user: null;
user: { id?: string; email?: string; [key: string]: unknown } | null;
loading: boolean;
error: Error | null;
signOut: () => Promise<void>;
@@ -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 (01) 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';
@@ -214,6 +214,40 @@ function FileContextInner({
return stirlingFiles;
}, [enablePersistence, requestConfirmation]);
const addFilesWithOptions = useCallback(
async (
files: File[],
options?: {
insertAfterPageId?: string;
selectFiles?: boolean;
autoUnzip?: boolean;
autoUnzipFileLimit?: number;
skipAutoUnzip?: boolean;
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>;
allowDuplicates?: boolean;
}
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
{
files,
...options,
},
stateRef,
filesRef,
dispatch,
lifecycleManager,
enablePersistence
);
if (options?.selectFiles && stirlingFiles.length > 0) {
selectFiles(stirlingFiles);
}
return stirlingFiles;
},
[enablePersistence]
);
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager);
@@ -326,6 +360,7 @@ function FileContextInner({
const actions = useMemo<FileContextActions>(() => ({
...baseActions,
addFiles: addRawFiles,
addFilesWithOptions,
addStirlingFileStubs: addStirlingFileStubsAction,
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
// Remove from memory and cleanup resources
@@ -1,10 +1,20 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Button, Group, Modal, Stack, Text } from '@mantine/core';
import { fileStorage } from '@app/services/fileStorage';
import { useFileActions } from '@app/contexts/FileContext';
import { zipFileService } from '@app/services/zipFileService';
import { StirlingFileStub } from '@app/types/fileContext';
import { downloadFiles } from '@app/utils/downloadUtils';
import { FileId } from '@app/types/file';
import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import apiClient from '@app/services/apiClient';
import { alert } from '@app/components/toast';
import {
extractLatestFilesFromBundle,
parseContentDispositionFilename,
} from '@app/services/shareBundleUtils';
import { useTranslation } from 'react-i18next';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
export { pendingFilePathMappings } from '@app/services/pendingFilePathMappings';
@@ -12,6 +22,7 @@ export { pendingFilePathMappings } from '@app/services/pendingFilePathMappings';
interface FileManagerContextValue {
// State
activeSource: 'recent' | 'local' | 'drive';
storageFilter: 'all' | 'local' | 'sharedWithMe' | 'sharedByMe';
selectedFileIds: FileId[];
searchTerm: string;
selectedFiles: StirlingFileStub[];
@@ -26,6 +37,7 @@ interface FileManagerContextValue {
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onStorageFilterChange: (filter: 'all' | 'local' | 'sharedWithMe' | 'sharedByMe') => void;
onLocalFileClick: () => void;
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void;
@@ -41,8 +53,10 @@ interface FileManagerContextValue {
onToggleExpansion: (fileId: FileId) => void;
onAddToRecents: (file: StirlingFileStub) => void;
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
onMakeCopy: (file: StirlingFileStub) => Promise<void>;
onNewFilesSelect: (files: File[]) => void;
onGoogleDriveSelect: (files: File[]) => void;
refreshRecentFiles: () => Promise<void>;
// External props
recentFiles: StirlingFileStub[];
@@ -69,6 +83,8 @@ interface FileManagerProviderProps {
activeFileIds: FileId[];
}
type RemoteDeleteChoice = 'local' | 'server' | 'both' | 'leave' | 'cancel';
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
children,
recentFiles,
@@ -84,12 +100,19 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
activeFileIds,
}) => {
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [storageFilter, setStorageFilter] = useState<
'all' | 'local' | 'sharedWithMe' | 'sharedByMe'
>('all');
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
const fileInputRef = useRef<HTMLInputElement>(null);
const [deletePromptFile, setDeletePromptFile] = useState<StirlingFileStub | null>(null);
const deletePromptResolveRef = useRef<((choice: RemoteDeleteChoice) => void) | null>(null);
const { t } = useTranslation();
const { actions } = useFileActions();
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set());
@@ -117,8 +140,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
if (!recentFiles || recentFiles.length === 0) return [];
// Only return leaf files - history files will be handled by separate components
if (storageFilter === 'sharedWithMe') {
return recentFiles.filter(
(file) => file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink
);
}
if (storageFilter === 'sharedByMe') {
return recentFiles.filter(
(file) => file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks
);
}
if (storageFilter === 'local') {
return recentFiles.filter(
(file) => !file.remoteStorageId && !file.remoteSharedViaLink
);
}
return recentFiles;
}, [recentFiles]);
}, [recentFiles, storageFilter]);
const selectedFiles = selectedFileIds.length === 0 ? [] :
displayFiles.filter(file => selectedFilesSet.has(file.id));
@@ -138,6 +176,27 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, []);
const handleStorageFilterChange = useCallback((filter: 'all' | 'local' | 'sharedWithMe' | 'sharedByMe') => {
setStorageFilter(filter);
setSelectedFileIds([]);
setLastClickedIndex(null);
}, []);
const requestDeleteChoice = useCallback((file: StirlingFileStub): Promise<RemoteDeleteChoice> => {
return new Promise((resolve) => {
deletePromptResolveRef.current = resolve;
setDeletePromptFile(file);
});
}, []);
const resolveDeleteChoice = useCallback((choice: RemoteDeleteChoice) => {
const resolver = deletePromptResolveRef.current;
deletePromptResolveRef.current = null;
setDeletePromptFile(null);
resolver?.(choice);
}, []);
const handleLocalFileClick = useCallback(async () => {
console.log('[FileManager] Opening file dialog...');
@@ -282,6 +341,96 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Shared internal delete logic
const performFileDelete = useCallback(async (fileToRemove: StirlingFileStub, fileIndex: number) => {
let deleteChoice: RemoteDeleteChoice = 'local';
if (fileToRemove.remoteStorageId) {
deleteChoice = await requestDeleteChoice(fileToRemove);
if (deleteChoice === 'cancel') {
return;
}
}
const canDeleteServer = fileToRemove.remoteOwnedByCurrentUser !== false;
const shouldDeleteServer =
canDeleteServer && (deleteChoice === 'server' || deleteChoice === 'both');
const shouldDeleteLocal = deleteChoice === 'local' || deleteChoice === 'both';
const isServerOnly =
Boolean(fileToRemove.remoteStorageId) && fileToRemove.id.startsWith('server-');
const canLeaveShare =
fileToRemove.remoteOwnedByCurrentUser === false &&
!fileToRemove.remoteSharedViaLink &&
Boolean(fileToRemove.remoteStorageId);
if (deleteChoice === 'leave' && canLeaveShare && fileToRemove.remoteStorageId) {
try {
await apiClient.delete(
`/api/v1/storage/files/${fileToRemove.remoteStorageId}/shares/self`,
{ suppressErrorToast: true } as any
);
await refreshRecentFiles();
alert({
alertType: 'success',
title: t('fileManager.leaveShareSuccess', 'Removed from your shared list.'),
expandable: false,
durationMs: 2500,
});
return;
} catch (error) {
console.error('Failed to leave shared file:', error);
alert({
alertType: 'error',
title: t('fileManager.leaveShareFailed', 'Could not remove the shared file.'),
expandable: false,
durationMs: 3500,
});
return;
}
}
if (shouldDeleteServer && fileToRemove.remoteStorageId) {
try {
await apiClient.delete(
`/api/v1/storage/files/${fileToRemove.remoteStorageId}`,
{ suppressErrorToast: true } as any
);
} catch (error) {
console.error('Failed to delete file from server:', error);
alert({
alertType: 'error',
title: t(
'fileManager.removeServerFailed',
'Could not remove the file from the server.'
),
expandable: false,
durationMs: 3500,
});
return;
}
if (!shouldDeleteLocal) {
const originalFileId = (fileToRemove.originalFileId || fileToRemove.id) as FileId;
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
for (const stub of chain) {
await fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
});
}
await refreshRecentFiles();
alert({
alertType: 'success',
title: t('fileManager.removeServerSuccess', 'Removed from server.'),
expandable: false,
durationMs: 2500,
});
return;
}
}
if (shouldDeleteLocal && isServerOnly) {
await refreshRecentFiles();
return;
}
const deletedFileId = fileToRemove.id;
// Get all stored files to analyze lineages
@@ -332,7 +481,16 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Refresh to ensure consistent state
await refreshRecentFiles();
}, [getSafeFilesToDelete, setSelectedFileIds, setExpandedFileIds, setLoadedHistoryFiles, onFileRemove, refreshRecentFiles]);
}, [
getSafeFilesToDelete,
setSelectedFileIds,
setExpandedFileIds,
setLoadedHistoryFiles,
onFileRemove,
refreshRecentFiles,
requestDeleteChoice,
t,
]);
const handleFileRemove = useCallback(async (index: number) => {
const fileToRemove = filteredFiles[index];
@@ -603,6 +761,60 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [refreshRecentFiles]);
const handleMakeCopy = useCallback(
async (file: StirlingFileStub) => {
if (!file.remoteStorageId) {
return;
}
try {
const response = await apiClient.get(
`/api/v1/storage/files/${file.remoteStorageId}/download`,
{ responseType: 'blob', suppressErrorToast: true, skipAuthRedirect: true } as any
);
const contentType =
(response.headers &&
(response.headers['content-type'] || response.headers['Content-Type'])) ||
'';
const disposition =
(response.headers &&
(response.headers['content-disposition'] || response.headers['Content-Disposition'])) ||
'';
const filename = parseContentDispositionFilename(disposition) || file.name || 'shared-file';
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
const latestFiles = await extractLatestFilesFromBundle(
blob,
filename,
contentTypeValue
);
if (latestFiles.length > 0) {
await actions.addFilesWithOptions(latestFiles, {
selectFiles: true,
allowDuplicates: true,
autoUnzip: false,
skipAutoUnzip: false,
});
await refreshRecentFiles();
alert({
alertType: 'success',
title: t('fileManager.copyCreated', 'Copy saved to this device.'),
expandable: false,
durationMs: 2500,
});
}
} catch (error) {
console.error('Failed to create a copy:', error);
alert({
alertType: 'error',
title: t('fileManager.copyFailed', 'Could not create a copy.'),
expandable: false,
durationMs: 3500,
});
}
},
[actions, refreshRecentFiles, t]
);
// Cleanup blob URLs when component unmounts
useEffect(() => {
return () => {
@@ -618,6 +830,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
useEffect(() => {
if (!isOpen) {
setActiveSource('recent');
setStorageFilter('all');
setSelectedFileIds([]);
setSearchTerm('');
setLastClickedIndex(null);
@@ -627,6 +840,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const contextValue: FileManagerContextValue = useMemo(() => ({
// State
activeSource,
storageFilter,
selectedFileIds,
searchTerm,
selectedFiles,
@@ -641,6 +855,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Handlers
onSourceChange: handleSourceChange,
onStorageFilterChange: handleStorageFilterChange,
onLocalFileClick: handleLocalFileClick,
onFileSelect: handleFileSelect,
onFileRemove: handleFileRemove,
@@ -656,8 +871,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onToggleExpansion: handleToggleExpansion,
onAddToRecents: handleAddToRecents,
onUnzipFile: handleUnzipFile,
onMakeCopy: handleMakeCopy,
onNewFilesSelect,
onGoogleDriveSelect: handleGoogleDriveSelect,
refreshRecentFiles,
// External props
recentFiles,
@@ -665,6 +882,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
modalHeight,
}), [
activeSource,
storageFilter,
selectedFileIds,
searchTerm,
selectedFiles,
@@ -676,6 +894,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
isLoading,
activeFileIds,
handleSourceChange,
handleStorageFilterChange,
handleLocalFileClick,
handleFileSelect,
handleFileRemove,
@@ -691,16 +910,92 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
handleToggleExpansion,
handleAddToRecents,
handleUnzipFile,
handleMakeCopy,
onNewFilesSelect,
handleGoogleDriveSelect,
recentFiles,
isFileSupported,
modalHeight,
]);
refreshRecentFiles,
]);
const deletePromptIsServerOnly =
Boolean(deletePromptFile?.remoteStorageId) && deletePromptFile?.id?.startsWith('server-');
const deletePromptIsShared = deletePromptFile?.remoteOwnedByCurrentUser === false;
const deletePromptCanLeaveShare =
deletePromptIsShared &&
!deletePromptFile?.remoteSharedViaLink &&
Boolean(deletePromptFile?.remoteStorageId);
return (
<FileManagerContext.Provider value={contextValue}>
{children}
<Modal
opened={Boolean(deletePromptFile)}
onClose={() => resolveDeleteChoice('cancel')}
centered
title={t('fileManager.removeFileTitle', 'Remove file')}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Stack gap="sm">
<Text size="sm">
{deletePromptIsServerOnly
? deletePromptIsShared
? deletePromptCanLeaveShare
? t(
'fileManager.removeSharedServerOnlyPrompt',
'This file is shared with you and stored only on the server. Remove it from your list?'
)
: t(
'fileManager.removeSharedServerOnlyBlockedPrompt',
'This file is shared with you and stored only on the server.'
)
: t(
'fileManager.removeServerOnlyPrompt',
'This file is stored only on your server. Would you like to remove it from the server?'
)
: deletePromptIsShared
? t(
'fileManager.removeSharedPrompt',
'This file is shared with you. You can remove it from this device or your shared list.'
)
: t(
'fileManager.removeFilePrompt',
'This file is saved on this device and on your server. Where would you like to remove it from?'
)}
</Text>
<Text size="sm" c="dimmed">
{deletePromptFile?.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => resolveDeleteChoice('cancel')}>
{t('cancel', 'Cancel')}
</Button>
{!deletePromptIsServerOnly && (
<Button variant="light" onClick={() => resolveDeleteChoice('local')}>
{t('fileManager.removeLocalOnly', 'This device only')}
</Button>
)}
{deletePromptCanLeaveShare && (
<Button variant="light" onClick={() => resolveDeleteChoice('leave')}>
{t('fileManager.leaveShare', 'Remove from my list')}
</Button>
)}
{deletePromptFile?.remoteOwnedByCurrentUser !== false && (
<>
<Button variant="light" onClick={() => resolveDeleteChoice('server')}>
{t('fileManager.removeServerOnly', 'Server only')}
</Button>
{!deletePromptIsServerOnly && (
<Button color="red" onClick={() => resolveDeleteChoice('both')}>
{t('fileManager.removeBoth', 'Remove from both')}
</Button>
)}
</>
)}
</Group>
</Stack>
</Modal>
</FileManagerContext.Provider>
);
};
+255 -18
View File
@@ -5,6 +5,15 @@ import { useFileContext } from '@app/contexts/file/fileHooks';
import { StirlingFileStub } from '@app/types/fileContext';
import type { FileId } from '@app/types/file';
import { fileStorage } from '@app/services/fileStorage';
import apiClient from '@app/services/apiClient';
import { alert } from '@app/components/toast';
import {
extractLatestFilesFromBundle,
getShareBundleEntryRootId,
isZipBundle,
loadShareBundleEntries,
parseContentDispositionFilename,
} from '@app/services/shareBundleUtils';
interface FilesModalContextType {
isFilesModalOpen: boolean;
@@ -27,6 +36,152 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
const importBundleToWorkbench = useCallback(
async (
blob: Blob,
filename: string,
contentType: string,
remoteStorageId?: number,
remoteStorageUpdatedAt?: number,
remoteOwnerUsername?: string,
remoteOwnedByCurrentUser?: boolean,
remoteSharedViaLink?: boolean,
remoteShareToken?: string
): Promise<FileId[]> => {
const bundle = isZipBundle(contentType, filename) ? await loadShareBundleEntries(blob) : null;
if (bundle) {
const { manifest, rootOrder, sortedEntries, files } = bundle;
const stirlingFiles = await actions.addFilesWithOptions(files, {
selectFiles: false,
autoUnzip: false,
skipAutoUnzip: false,
allowDuplicates: true,
});
const idMap = new Map<string, FileId>();
for (let i = 0; i < stirlingFiles.length; i += 1) {
idMap.set(sortedEntries[i].logicalId, stirlingFiles[i].fileId as FileId);
}
const rootIdMap = new Map<string, FileId>();
for (const rootLogicalId of rootOrder) {
const mappedId = idMap.get(rootLogicalId);
if (mappedId) {
rootIdMap.set(rootLogicalId, mappedId);
}
}
const remoteUpdatedAt = remoteStorageUpdatedAt ?? Date.now();
for (const entry of sortedEntries) {
const newId = idMap.get(entry.logicalId);
if (!newId) continue;
const parentId = entry.parentLogicalId
? idMap.get(entry.parentLogicalId)
: undefined;
const rootId =
rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) ||
idMap.get(manifest.rootLogicalId) ||
newId;
const updates = {
versionNumber: entry.versionNumber,
originalFileId: rootId,
parentFileId: parentId,
toolHistory: entry.toolHistory,
isLeaf: entry.isLeaf,
remoteStorageId,
remoteStorageUpdatedAt: remoteUpdatedAt,
remoteOwnerUsername,
remoteOwnedByCurrentUser,
remoteSharedViaLink,
remoteShareToken,
};
actions.updateStirlingFileStub(newId, updates);
await fileStorage.updateFileMetadata(newId, updates);
}
const selectedIds: FileId[] = [];
for (const rootId of rootOrder) {
const rootEntries = sortedEntries.filter(
(entry) => getShareBundleEntryRootId(manifest, entry) === rootId
);
const latestEntry = rootEntries[rootEntries.length - 1];
if (!latestEntry) {
continue;
}
const latestId = idMap.get(latestEntry.logicalId);
if (latestId) {
selectedIds.push(latestId);
}
}
return selectedIds;
}
const file = new File([blob], filename, { type: contentType || blob.type });
const stirlingFiles = await actions.addFilesWithOptions([file], {
selectFiles: false,
autoUnzip: false,
skipAutoUnzip: false,
allowDuplicates: true,
});
const fileId = stirlingFiles[0]?.fileId as FileId | undefined;
if (fileId && remoteStorageId) {
const remoteUpdatedAt = remoteStorageUpdatedAt ?? Date.now();
const updates = {
remoteStorageId,
remoteStorageUpdatedAt: remoteUpdatedAt,
remoteOwnerUsername,
remoteOwnedByCurrentUser,
remoteSharedViaLink,
remoteShareToken,
};
actions.updateStirlingFileStub(fileId, updates);
await fileStorage.updateFileMetadata(fileId, updates);
}
return fileId ? [fileId] : [];
},
[actions, fileStorage]
);
const downloadServerFile = useCallback(async (remoteId: number) => {
const response = await apiClient.get(`/api/v1/storage/files/${remoteId}/download`, {
responseType: 'blob',
suppressErrorToast: true,
skipAuthRedirect: true,
} as any);
const contentType =
(response.headers && (response.headers['content-type'] || response.headers['Content-Type'])) ||
'';
const disposition =
(response.headers &&
(response.headers['content-disposition'] || response.headers['Content-Disposition'])) ||
'';
const filename = parseContentDispositionFilename(disposition) || 'server-file';
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
return { blob, filename, contentType: contentTypeValue };
}, []);
const downloadShareLinkFile = useCallback(async (shareToken: string) => {
const response = await apiClient.get(`/api/v1/storage/share-links/${shareToken}`, {
responseType: 'blob',
suppressErrorToast: true,
skipAuthRedirect: true,
} as any);
const contentType =
(response.headers && (response.headers['content-type'] || response.headers['Content-Type'])) ||
'';
const disposition =
(response.headers &&
(response.headers['content-disposition'] || response.headers['Content-Disposition'])) ||
'';
const filename = parseContentDispositionFilename(disposition) || 'shared-file';
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
return { blob, filename, contentType: contentTypeValue };
}, []);
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => {
setInsertAfterPage(options?.insertAfterPage);
setCustomHandler(() => options?.customHandler);
@@ -61,40 +216,122 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
}, [addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx]);
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
const serverOnlyStubs = stirlingFileStubs.filter(
(stub) => stub.remoteStorageId && stub.id.startsWith('server-')
);
const sharedLinkStubs = stirlingFileStubs.filter(
(stub) => stub.remoteShareToken
);
const localStubs = stirlingFileStubs.filter(
(stub) => !serverOnlyStubs.includes(stub) && !sharedLinkStubs.includes(stub)
);
if (customHandler) {
// Load the actual files from storage for custom handler
try {
const loadedFiles: File[] = [];
for (const stub of stirlingFileStubs) {
for (const stub of localStubs) {
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
if (stirlingFile) {
loadedFiles.push(stirlingFile);
}
}
for (const stub of serverOnlyStubs) {
if (!stub.remoteStorageId) continue;
const { blob, filename, contentType } = await downloadServerFile(stub.remoteStorageId);
const latestFiles = await extractLatestFilesFromBundle(blob, filename, contentType);
loadedFiles.push(...latestFiles);
}
for (const stub of sharedLinkStubs) {
if (!stub.remoteShareToken) continue;
const { blob, filename, contentType } = await downloadShareLinkFile(stub.remoteShareToken);
const latestFiles = await extractLatestFilesFromBundle(blob, filename, contentType);
loadedFiles.push(...latestFiles);
}
if (loadedFiles.length > 0) {
customHandler(loadedFiles, insertAfterPage);
}
} catch (error) {
console.error('Failed to load files for custom handler:', error);
alert({
alertType: 'error',
title: 'Unable to download one or more server files.',
expandable: false,
durationMs: 3500,
});
}
} else {
// Normal case - use addStirlingFileStubs to preserve metadata (auto-selects new)
if (actions.addStirlingFileStubs) {
await actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
// Merge all requested IDs into selection (covers files that already existed)
const requestedIds = stirlingFileStubs.map((s) => s.id);
if (requestedIds.length > 0) {
const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id);
const nextSelection = Array.from(new Set([...currentSelected, ...requestedIds]));
actions.setSelectedFiles(nextSelection);
}
} else {
console.error('addStirlingFileStubs action not available');
}
closeFilesModal();
return;
}
const selectedFromServer: FileId[] = [];
try {
for (const stub of serverOnlyStubs) {
if (!stub.remoteStorageId) continue;
const { blob, filename, contentType } = await downloadServerFile(stub.remoteStorageId);
const importedIds = await importBundleToWorkbench(
blob,
filename,
contentType,
stub.remoteStorageId,
stub.remoteStorageUpdatedAt,
stub.remoteOwnerUsername,
stub.remoteOwnedByCurrentUser,
stub.remoteSharedViaLink,
stub.remoteShareToken
);
selectedFromServer.push(...importedIds);
}
for (const stub of sharedLinkStubs) {
if (!stub.remoteShareToken) continue;
const { blob, filename, contentType } = await downloadShareLinkFile(stub.remoteShareToken);
const importedIds = await importBundleToWorkbench(
blob,
filename,
contentType,
stub.remoteStorageId,
stub.remoteStorageUpdatedAt,
stub.remoteOwnerUsername,
stub.remoteOwnedByCurrentUser,
true,
stub.remoteShareToken
);
selectedFromServer.push(...importedIds);
}
} catch (error) {
console.error('Failed to load server files:', error);
alert({
alertType: 'error',
title: 'Unable to download one or more server files.',
expandable: false,
durationMs: 3500,
});
}
if (actions.addStirlingFileStubs) {
await actions.addStirlingFileStubs(localStubs, { selectFiles: false });
const requestedIds = localStubs.map((s) => s.id);
const nextSelection = Array.from(
new Set([...requestedIds, ...selectedFromServer])
);
actions.setSelectedFiles(nextSelection);
} else {
console.error('addStirlingFileStubs action not available');
}
closeFilesModal();
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage, actions, fileCtx]);
}, [
actions.addStirlingFileStubs,
actions,
closeFilesModal,
customHandler,
insertAfterPage,
fileCtx,
downloadServerFile,
downloadShareLinkFile,
extractLatestFilesFromBundle,
importBundleToWorkbench,
]);
const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback);
@@ -32,6 +32,8 @@ export interface CustomWorkbenchViewRegistration {
label: string;
icon?: React.ReactNode;
component: React.ComponentType<{ data: any }>;
hideTopControls?: boolean;
hideToolPanel?: boolean;
}
export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration {
@@ -201,8 +203,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
}
}, [actions, navigationState.workbench]);
const setCustomWorkbenchViewData = useCallback((id: string, data: any) => {
setCustomViewData(prev => ({ ...prev, [id]: data }));
const setCustomWorkbenchViewData = useCallback((id: string, dataOrUpdater: any | ((prev: any) => any)) => {
setCustomViewData(prev => {
const currentData = prev[id];
const newData = typeof dataOrUpdater === 'function'
? dataOrUpdater(currentData)
: dataOrUpdater;
return { ...prev, [id]: newData };
});
}, []);
const clearCustomWorkbenchViewData = useCallback((id: string) => {
@@ -234,6 +234,7 @@ interface AddFileOptions {
autoUnzipFileLimit?: number;
skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs.
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>; // Optional callback to confirm extraction of large ZIP files
allowDuplicates?: boolean;
}
/**
@@ -258,7 +259,7 @@ export async function addFiles(
// Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
const { files = [] } = options;
const { files = [], allowDuplicates = false } = options;
// ZIP pre-processing: Extract ZIP files with configurable behavior
// - File uploads: skipAutoUnzip=true → always extract (except HTML)
@@ -317,7 +318,7 @@ export async function addFiles(
const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) {
if (!allowDuplicates && existingQuickKeys.has(quickKey)) {
continue;
}
@@ -329,7 +330,7 @@ export async function addFiles(
// Check for pending file path mapping from Tauri file dialog (desktop only)
try {
const { pendingFilePathMappings } = await import('@app/contexts/FileManagerContext');
const { pendingFilePathMappings } = await import('@app/services/pendingFilePathMappings');
console.log(`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`);
console.log(`[FileActions] Available mappings:`, Array.from(pendingFilePathMappings.keys()));
const localFilePath = pendingFilePathMappings.get(quickKey);
@@ -351,7 +352,9 @@ export async function addFiles(
fileStub.insertAfterPageId = options.insertAfterPageId;
}
existingQuickKeys.add(quickKey);
if (!allowDuplicates) {
existingQuickKeys.add(quickKey);
}
stirlingFileStubs.push(fileStub);
// Dispatch immediately so each file appears as soon as it is processed
@@ -0,0 +1,93 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { alert } from '@app/components/toast';
import { SignRequestSummary, SessionSummary } from '@app/types/signingSession';
export interface UseSigningSessionsOptions {
enabled?: boolean;
autoRefreshInterval?: number; // milliseconds, 0 to disable
}
export interface UseSigningSessionsResult {
signRequests: SignRequestSummary[];
mySessions: SessionSummary[];
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook to fetch signing sessions data (sign requests and user's sessions).
* Supports auto-refresh for real-time updates.
*/
export const useSigningSessions = (
options: UseSigningSessionsOptions = {}
): UseSigningSessionsResult => {
const { enabled = true, autoRefreshInterval = 0 } = options;
const { t } = useTranslation();
const [signRequests, setSignRequests] = useState<SignRequestSummary[]>([]);
const [mySessions, setMySessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
if (!enabled) return;
setLoading(true);
setError(null);
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 (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to fetch signing data');
setError(errorObj);
console.error('Failed to fetch signing data:', err);
alert({
alertType: 'warning',
title: t('common.error'),
body: t('certSign.fetchFailed', 'Failed to load signing data'),
expandable: false,
durationMs: 2500,
});
} finally {
setLoading(false);
}
}, [enabled, t]);
// Initial fetch
useEffect(() => {
if (enabled) {
fetchData();
}
}, [enabled, fetchData]);
// Auto-refresh
useEffect(() => {
if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) {
return;
}
const interval = setInterval(() => {
fetchData();
}, autoRefreshInterval);
return () => clearInterval(interval);
}, [enabled, autoRefreshInterval, fetchData]);
return {
signRequests,
mySessions,
loading,
error,
refetch: fetchData,
};
};
@@ -0,0 +1,77 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import SignRequestWorkbenchView from '@app/components/tools/certSign/SignRequestWorkbenchView';
import SessionDetailWorkbenchView from '@app/components/tools/certSign/SessionDetailWorkbenchView';
export interface WorkbenchRegistration {
id: string;
workbenchId: string;
label: string;
component: React.ComponentType<any>;
}
export interface UseSigningWorkbenchResult {
signRequestWorkbench: {
id: string;
type: string;
};
sessionDetailWorkbench: {
id: string;
type: string;
};
}
/**
* Hook to manage custom workbench registration for signing workflows.
* Automatically registers and unregisters workbenches on mount/unmount.
*/
export const useSigningWorkbench = (): UseSigningWorkbenchResult => {
const { t } = useTranslation();
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
} = useToolWorkflow();
// Define workbench IDs as constants
const SIGN_REQUEST_WORKBENCH_ID = 'signRequestWorkbench';
const SIGN_REQUEST_WORKBENCH_TYPE = 'custom:signRequestWorkbench' as const;
const SESSION_DETAIL_WORKBENCH_ID = 'sessionDetailWorkbench';
const SESSION_DETAIL_WORKBENCH_TYPE = 'custom:sessionDetailWorkbench' as const;
// Register workbenches on mount
useEffect(() => {
registerCustomWorkbenchView({
id: SIGN_REQUEST_WORKBENCH_ID,
workbenchId: SIGN_REQUEST_WORKBENCH_TYPE,
label: t('certSign.collab.signRequest.workbenchTitle', 'Sign Request'),
component: SignRequestWorkbenchView,
});
registerCustomWorkbenchView({
id: SESSION_DETAIL_WORKBENCH_ID,
workbenchId: SESSION_DETAIL_WORKBENCH_TYPE,
label: t('certSign.collab.sessionDetail.workbenchTitle', 'Session Management'),
component: SessionDetailWorkbenchView,
});
return () => {
unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID);
unregisterCustomWorkbenchView(SESSION_DETAIL_WORKBENCH_ID);
};
}, [registerCustomWorkbenchView, unregisterCustomWorkbenchView, t]);
return useMemo(
() => ({
signRequestWorkbench: {
id: SIGN_REQUEST_WORKBENCH_ID,
type: SIGN_REQUEST_WORKBENCH_TYPE,
},
sessionDetailWorkbench: {
id: SESSION_DETAIL_WORKBENCH_ID,
type: SESSION_DETAIL_WORKBENCH_TYPE,
},
}),
[]
);
};
+294 -2
View File
@@ -3,10 +3,74 @@ import { useIndexedDB } from '@app/contexts/IndexedDBContext';
import { fileStorage } from '@app/services/fileStorage';
import { StirlingFileStub, StirlingFile } from '@app/types/fileContext';
import { FileId } from '@app/types/fileContext';
import apiClient from '@app/services/apiClient';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface StoredFileResponse {
id: number;
fileName: string;
contentType?: string | null;
sizeBytes: number;
createdAt?: string | null;
updatedAt?: string | null;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
shareLinks?: Array<{ token?: string | null }>;
sharedWithUsers?: string[];
filePurpose?: string | null;
}
interface AccessedShareLinkResponse {
shareToken?: string | null;
fileId?: number | null;
fileName?: string | null;
owner?: string | null;
ownedByCurrentUser?: boolean;
createdAt?: string | null;
lastAccessedAt?: string | null;
}
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
const indexedDB = useIndexedDB();
const { config } = useAppConfig();
const normalizeServerFileName = useCallback((fileName: string | undefined | null): string => {
const fallback = fileName?.trim() || 'server-file';
const lowerName = fallback.toLowerCase();
const historySuffix = '-history.zip';
if (lowerName.endsWith(historySuffix)) {
return fallback.slice(0, fallback.length - historySuffix.length) || fallback;
}
if (lowerName.endsWith('.zip')) {
const knownInnerExt = [
'pdf',
'doc',
'docx',
'ppt',
'pptx',
'xls',
'xlsx',
'png',
'jpg',
'jpeg',
'tif',
'tiff',
'txt',
'csv',
'rtf',
'html',
'epub',
];
for (const ext of knownInnerExt) {
if (lowerName.endsWith(`.${ext}.zip`)) {
return fallback.slice(0, fallback.length - 4) || fallback;
}
}
}
return fallback;
}, []);
const convertToFile = useCallback(async (fileStub: StirlingFileStub): Promise<File> => {
if (!indexedDB) {
@@ -32,9 +96,237 @@ export const useFileManager = () => {
// Load only leaf files metadata (processed files that haven't been used as input for other tools)
const stirlingFileStubs = await fileStorage.getLeafStirlingFileStubs();
const remoteIdSet = new Set(
stirlingFileStubs
.map((stub) => stub.remoteStorageId)
.filter((id): id is number => typeof id === 'number')
);
let combinedStubs = stirlingFileStubs;
const shouldFetchServerFiles = config?.storageEnabled === true;
if (shouldFetchServerFiles) {
try {
const response = await apiClient.get<StoredFileResponse[]>(
'/api/v1/storage/files',
{ suppressErrorToast: true, skipAuthRedirect: true } as any
);
const serverFiles = Array.isArray(response.data) ? response.data : [];
const serverStubs: StirlingFileStub[] = [];
const serverMap = new Map<number, StoredFileResponse>();
serverFiles.forEach((file) => {
if (file && typeof file.id === 'number') {
serverMap.set(file.id, file);
}
});
const updatedLocalStubs = stirlingFileStubs.map((stub) => {
if (!stub.remoteStorageId) {
return stub;
}
const serverFile = serverMap.get(stub.remoteStorageId);
if (!serverFile) {
if (stub.remoteSharedViaLink) {
return {
...stub,
remoteOwnedByCurrentUser: false,
};
}
return {
...stub,
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteAccessRole: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
};
}
// If this server file is a signing-workflow file, detach it from the file manager
if (serverFile.filePurpose && serverFile.filePurpose !== 'generic') {
return {
...stub,
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteAccessRole: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
};
}
const updatedAtMs = serverFile.updatedAt
? new Date(serverFile.updatedAt).getTime()
: serverFile.createdAt
? new Date(serverFile.createdAt).getTime()
: undefined;
return {
...stub,
remoteOwnerUsername: serverFile.owner ?? stub.remoteOwnerUsername,
remoteOwnedByCurrentUser:
typeof serverFile.ownedByCurrentUser === 'boolean'
? serverFile.ownedByCurrentUser
: stub.remoteOwnedByCurrentUser,
remoteAccessRole: serverFile.accessRole ?? stub.remoteAccessRole,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: Boolean(serverFile.shareLinks?.length),
remoteStorageUpdatedAt:
typeof updatedAtMs === 'number' && Number.isFinite(updatedAtMs)
? updatedAtMs
: stub.remoteStorageUpdatedAt,
};
});
for (const file of serverFiles) {
if (!file || typeof file.id !== 'number') {
continue;
}
if (remoteIdSet.has(file.id)) {
continue;
}
// Skip signing-workflow files — only accessible via SignPopout
if (file.filePurpose && file.filePurpose !== 'generic') {
continue;
}
const updatedAtMs = file.updatedAt
? new Date(file.updatedAt).getTime()
: file.createdAt
? new Date(file.createdAt).getTime()
: Date.now();
const name = normalizeServerFileName(file.fileName);
const lastModified = Number.isFinite(updatedAtMs) ? updatedAtMs : Date.now();
const id = `server-${file.id}` as FileId;
serverStubs.push({
id,
name,
type: file.contentType || 'application/octet-stream',
size: file.sizeBytes ?? 0,
lastModified,
createdAt: lastModified,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
toolHistory: [],
quickKey: `${name}|${file.sizeBytes ?? 0}|${lastModified}`,
remoteStorageId: file.id,
remoteStorageUpdatedAt: lastModified,
remoteOwnerUsername: file.owner ?? undefined,
remoteOwnedByCurrentUser:
typeof file.ownedByCurrentUser === 'boolean'
? file.ownedByCurrentUser
: undefined,
remoteAccessRole: file.accessRole ?? undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: Boolean(file.shareLinks?.length),
});
}
combinedStubs = [...updatedLocalStubs, ...serverStubs];
} catch (error) {
console.warn('Failed to load server files:', error);
}
if (config?.storageShareLinksEnabled === true) {
try {
const sharedResponse = await apiClient.get<AccessedShareLinkResponse[]>(
'/api/v1/storage/share-links/accessed',
{ suppressErrorToast: true, skipAuthRedirect: true } as any
);
const sharedLinks = Array.isArray(sharedResponse.data) ? sharedResponse.data : [];
const allowedShareTokens = new Set(
sharedLinks
.map((link) => link.shareToken)
.filter((token): token is string => Boolean(token))
);
const shareClearUpdates: Array<Promise<boolean>> = [];
combinedStubs = combinedStubs.map((stub) => {
if (
stub.remoteSharedViaLink &&
stub.remoteShareToken &&
!allowedShareTokens.has(stub.remoteShareToken)
) {
const cleared = {
...stub,
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
remoteShareToken: undefined,
};
shareClearUpdates.push(
fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
remoteShareToken: undefined,
})
);
return cleared;
}
return stub;
});
if (shareClearUpdates.length > 0) {
await Promise.all(shareClearUpdates);
}
const existingShareTokens = new Set(
combinedStubs
.map((stub) => stub.remoteShareToken)
.filter((token): token is string => Boolean(token))
);
const sharedStubs: StirlingFileStub[] = [];
for (const link of sharedLinks) {
if (!link || !link.shareToken) {
continue;
}
if (existingShareTokens.has(link.shareToken)) {
continue;
}
const createdAtMs = link.lastAccessedAt
? new Date(link.lastAccessedAt).getTime()
: link.createdAt
? new Date(link.createdAt).getTime()
: Date.now();
const lastModified = Number.isFinite(createdAtMs) ? createdAtMs : Date.now();
const name = normalizeServerFileName(link.fileName || 'shared-file');
const id = `shared-${link.shareToken}` as FileId;
sharedStubs.push({
id,
name,
type: 'application/octet-stream',
size: 0,
lastModified,
createdAt: lastModified,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
toolHistory: [],
quickKey: `${name}|0|${lastModified}`,
remoteStorageId: link.fileId ?? undefined,
remoteStorageUpdatedAt: lastModified,
remoteOwnerUsername: link.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: link.shareToken,
});
}
combinedStubs = [...combinedStubs, ...sharedStubs];
} catch (error) {
console.warn('Failed to load shared links:', error);
}
}
}
// For now, only regular files - drafts will be handled separately in the future
const sortedFiles = stirlingFileStubs.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
const sortedFiles = combinedStubs.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
return sortedFiles;
} catch (error) {
@@ -43,7 +335,7 @@ export const useFileManager = () => {
} finally {
setLoading(false);
}
}, [indexedDB]);
}, [indexedDB, config?.enableLogin, config?.storageEnabled, config?.storageShareLinksEnabled, normalizeServerFileName]);
const handleRemoveFile = useCallback(async (index: number, files: StirlingFileStub[], setFiles: (files: StirlingFileStub[]) => void) => {
const file = files[index];
+8
View File
@@ -7,3 +7,11 @@ import { useMediaQuery } from '@mantine/hooks';
export const useIsMobile = (): boolean => {
return useMediaQuery('(max-width: 1024px)') ?? false;
};
/**
* Custom hook to detect phone-sized viewport (768px)
* Use for layouts that need a more compact single-column arrangement
*/
export const useIsPhone = (): boolean => {
return useMediaQuery('(max-width: 768px)') ?? false;
};
+14 -1
View File
@@ -45,6 +45,7 @@ export default function HomePage() {
readerMode,
setLeftPanelView,
toolAvailability,
customWorkbenchViews,
} = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
@@ -104,6 +105,10 @@ export default function HomePage() {
navigationState.workbench,
]);
const hideToolPanel = customWorkbenchViews.find(
(v) => v.workbenchId === navigationState.workbench
)?.hideToolPanel ?? false;
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath();
const { wordmark } = useLogoAssets();
@@ -178,6 +183,14 @@ export default function HomePage() {
}
}, [isMobile, readerMode, selectedToolKey]);
// Automatically switch to workbench slide when a custom workbench (e.g. signing) is active on mobile.
// hideToolPanel is true for all custom workbenches that take over the full screen.
useEffect(() => {
if (isMobile && hideToolPanel) {
setActiveMobileView('workbench');
}
}, [isMobile, hideToolPanel]);
// When navigating back to tools view in mobile with a workbench-only tool, show tool picker
useEffect(() => {
if (isMobile && activeMobileView === 'tools' && selectedTool) {
@@ -314,7 +327,7 @@ export default function HomePage() {
className="flex-nowrap flex"
>
<QuickAccessBar ref={quickAccessRef} />
<ToolPanel />
{!hideToolPanel && <ToolPanel />}
<Workbench />
<RightRail />
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
+78 -3
View File
@@ -53,9 +53,18 @@ class FileStorageService {
type: stirlingFile.type,
size: stirlingFile.size,
lastModified: stirlingFile.lastModified,
createdAt: stub.createdAt,
data: arrayBuffer,
thumbnail: stub.thumbnailUrl,
isLeaf: stub.isLeaf ?? true,
remoteStorageId: stub.remoteStorageId,
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
remoteOwnerUsername: stub.remoteOwnerUsername,
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
remoteAccessRole: stub.remoteAccessRole,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: stub.remoteHasShareLinks,
remoteShareToken: stub.remoteShareToken,
// History data from stub
versionNumber: stub.versionNumber ?? 1,
@@ -160,11 +169,19 @@ class FileStorageService {
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber,
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory,
createdAt: Date.now() // Current session
createdAt: record.createdAt || Date.now()
};
resolve(stub);
@@ -200,11 +217,19 @@ class FileStorageService {
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: Date.now()
createdAt: record.createdAt || Date.now()
});
}
cursor.continue();
@@ -215,6 +240,16 @@ class FileStorageService {
});
}
/**
* Get all history stubs for a given original file ID.
*/
async getHistoryChainStubs(originalFileId: FileId): Promise<StirlingFileStub[]> {
const stubs = await this.getAllStirlingFileStubs();
return stubs
.filter((stub) => (stub.originalFileId || stub.id) === originalFileId)
.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
}
/**
* Get leaf StirlingFileStubs only - for unprocessed files
*/
@@ -243,11 +278,19 @@ class FileStorageService {
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: Date.now()
createdAt: record.createdAt || Date.now()
});
}
cursor.continue();
@@ -473,6 +516,38 @@ class FileStorageService {
return false;
}
}
/**
* Update metadata fields for a stored file record.
*/
async updateFileMetadata(fileId: FileId, updates: Partial<StoredStirlingFileRecord>): Promise<boolean> {
try {
const db = await this.getDatabase();
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
const request = store.get(fileId);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as StoredStirlingFileRecord | undefined);
});
if (!record) {
return false;
}
const updatedRecord = { ...record, ...updates };
await new Promise<void>((resolve, reject) => {
const request = store.put(updatedRecord);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
return true;
} catch (error) {
console.error('Failed to update file metadata:', error);
return false;
}
}
}
// Export singleton instance
@@ -0,0 +1,169 @@
import JSZip from 'jszip';
import { fileStorage } from '@app/services/fileStorage';
import type { FileId, ToolOperation } from '@app/types/file';
import type { StirlingFileStub } from '@app/types/fileContext';
interface ShareBundleEntry {
logicalId: string;
rootLogicalId: string;
parentLogicalId?: string;
versionNumber: number;
name: string;
type: string;
size: number;
lastModified: number;
toolHistory?: ToolOperation[];
filePath: string;
isLeaf: boolean;
}
export interface ShareBundleManifest {
schemaVersion: 1;
rootLogicalId: string;
rootLogicalIds: string[];
createdAt: number;
entries: ShareBundleEntry[];
}
function sanitizeFilename(name: string): string {
const trimmed = name?.trim();
if (!trimmed) return 'file';
return trimmed.replace(/[\\/:*?"<>|]/g, '_');
}
export async function buildHistoryBundle(originalFileIds: FileId[] | FileId): Promise<{
bundleFile: File;
manifest: ShareBundleManifest;
}> {
const roots = Array.isArray(originalFileIds) ? originalFileIds : [originalFileIds];
const uniqueRoots = Array.from(new Set(roots));
const allStubs: Array<{ rootId: FileId; stubs: Awaited<ReturnType<typeof fileStorage.getHistoryChainStubs>> }> = [];
for (const rootId of uniqueRoots) {
const stubs = await fileStorage.getHistoryChainStubs(rootId);
if (stubs.length === 0) {
throw new Error('No history chain found for file.');
}
allStubs.push({ rootId, stubs });
}
const zip = new JSZip();
const entries: ShareBundleEntry[] = [];
for (const chain of allStubs) {
for (const stub of chain.stubs) {
const file = await fileStorage.getStirlingFile(stub.id);
if (!file) {
throw new Error(`Missing file data for ${stub.name || stub.id}`);
}
const logicalId = stub.id;
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`;
const buffer = await file.arrayBuffer();
zip.file(filePath, buffer);
entries.push({
logicalId,
rootLogicalId: chain.rootId,
parentLogicalId: stub.parentFileId,
versionNumber: stub.versionNumber || 1,
name: stub.name,
type: stub.type,
size: stub.size,
lastModified: stub.lastModified,
toolHistory: stub.toolHistory,
filePath,
isLeaf: Boolean(stub.isLeaf),
});
}
}
const manifest: ShareBundleManifest = {
schemaVersion: 1,
rootLogicalId: uniqueRoots[0],
rootLogicalIds: uniqueRoots,
createdAt: Date.now(),
entries,
};
zip.file('stirling-share.json', JSON.stringify(manifest, null, 2));
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
const firstStubName = allStubs[0]?.stubs[0]?.name || 'shared';
const rootName = sanitizeFilename(firstStubName);
const bundleFile = new File([zipBlob], `${rootName}-history.zip`, {
type: 'application/zip',
lastModified: Date.now(),
});
return { bundleFile, manifest };
}
export async function buildSharePackage(
stubs: StirlingFileStub[]
): Promise<{
bundleFile: File;
manifest: ShareBundleManifest;
}> {
if (stubs.length === 0) {
throw new Error('No files provided for sharing.');
}
const zip = new JSZip();
const entries: ShareBundleEntry[] = [];
for (const stub of stubs) {
const file = await fileStorage.getStirlingFile(stub.id as FileId);
if (!file) {
throw new Error(`Missing file data for ${stub.name || stub.id}`);
}
const logicalId = stub.id as string;
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`;
const buffer = await file.arrayBuffer();
zip.file(filePath, buffer);
entries.push({
logicalId,
rootLogicalId: logicalId,
versionNumber: stub.versionNumber || 1,
name: stub.name,
type: stub.type,
size: stub.size,
lastModified: stub.lastModified,
toolHistory: stub.toolHistory,
filePath,
isLeaf: true,
});
}
const rootLogicalIds = entries.map((entry) => entry.logicalId);
const manifest: ShareBundleManifest = {
schemaVersion: 1,
rootLogicalId: rootLogicalIds[0],
rootLogicalIds,
createdAt: Date.now(),
entries,
};
zip.file('stirling-share.json', JSON.stringify(manifest, null, 2));
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
const bundleFile = new File([zipBlob], `shared-files.zip`, {
type: 'application/zip',
lastModified: Date.now(),
});
return { bundleFile, manifest };
}
@@ -0,0 +1,125 @@
import apiClient from '@app/services/apiClient';
import { fileStorage } from '@app/services/fileStorage';
import { buildHistoryBundle, buildSharePackage } from '@app/services/serverStorageBundle';
import type { FileId } from '@app/types/file';
import type { StirlingFileStub } from '@app/types/fileContext';
function resolveUpdatedAt(value: unknown): number {
if (!value) {
return Date.now();
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : Date.now();
}
const parsed = new Date(String(value)).getTime();
return Number.isFinite(parsed) ? parsed : Date.now();
}
export async function uploadHistoryChain(
originalFileId: FileId,
existingRemoteId?: number
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
if (chain.length === 0) {
throw new Error('No history chain found.');
}
const finalStub =
chain.slice().reverse().find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
const finalFile = await fileStorage.getStirlingFile(finalStub.id);
if (!finalFile) {
throw new Error('Missing final file data for sharing.');
}
const { bundleFile, manifest } = await buildHistoryBundle(originalFileId);
const auditLog = new File([JSON.stringify(manifest, null, 2)], 'audit-log.json', {
type: 'application/json',
lastModified: Date.now(),
});
const formData = new FormData();
formData.append('file', finalFile, finalFile.name);
formData.append('historyBundle', bundleFile, bundleFile.name);
formData.append('auditLog', auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(`/api/v1/storage/files/${existingRemoteId}`, formData);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain };
}
const response = await apiClient.post('/api/v1/storage/files', formData);
const remoteId = response.data?.id as number | undefined;
if (!remoteId) {
throw new Error('Missing stored file ID in response.');
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain };
}
export async function uploadHistoryChains(
originalFileIds: FileId[],
existingRemoteId?: number
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
const uniqueRoots = Array.from(new Set(originalFileIds));
const chainMap = new Map<FileId, StirlingFileStub[]>();
const combinedChain: StirlingFileStub[] = [];
const seenIds = new Set<FileId>();
const leafStubs: StirlingFileStub[] = [];
for (const rootId of uniqueRoots) {
const chain = await fileStorage.getHistoryChainStubs(rootId);
if (chain.length === 0) {
throw new Error('No history chain found.');
}
chainMap.set(rootId, chain);
const finalStub =
chain.slice().reverse().find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
if (finalStub) {
leafStubs.push(finalStub);
}
for (const stub of chain) {
if (!seenIds.has(stub.id as FileId)) {
seenIds.add(stub.id as FileId);
combinedChain.push(stub);
}
}
}
let shareFile: File;
if (leafStubs.length === 1) {
const finalFile = await fileStorage.getStirlingFile(leafStubs[0].id);
if (!finalFile) {
throw new Error('Missing final file data for sharing.');
}
shareFile = finalFile;
} else {
const { bundleFile } = await buildSharePackage(leafStubs);
shareFile = bundleFile;
}
const { bundleFile, manifest } = await buildHistoryBundle(uniqueRoots);
const auditLog = new File([JSON.stringify(manifest, null, 2)], 'audit-log.json', {
type: 'application/json',
lastModified: Date.now(),
});
const formData = new FormData();
formData.append('file', shareFile, shareFile.name);
formData.append('historyBundle', bundleFile, bundleFile.name);
formData.append('auditLog', auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(`/api/v1/storage/files/${existingRemoteId}`, formData);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain: combinedChain };
}
const response = await apiClient.post('/api/v1/storage/files', formData);
const remoteId = response.data?.id as number | undefined;
if (!remoteId) {
throw new Error('Missing stored file ID in response.');
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain: combinedChain };
}
@@ -0,0 +1,119 @@
import JSZip from 'jszip';
import type { ShareBundleManifest } from '@app/services/serverStorageBundle';
const MANIFEST_FILENAME = 'stirling-share.json';
export function parseContentDispositionFilename(disposition?: string): string | null {
if (!disposition) return null;
const filenameMatch = /filename="([^"]+)"/i.exec(disposition);
if (filenameMatch?.[1]) return filenameMatch[1];
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch {
return utf8Match[1];
}
}
return null;
}
export function isZipBundle(contentType: string, filename: string): boolean {
return contentType.includes('zip') || filename.toLowerCase().endsWith('.zip');
}
export function getShareBundleEntryRootId(
manifest: ShareBundleManifest,
entry: ShareBundleManifest['entries'][number]
): string {
return entry.rootLogicalId || manifest.rootLogicalId;
}
export function resolveShareBundleOrder(manifest: ShareBundleManifest): {
rootOrder: string[];
sortedEntries: ShareBundleManifest['entries'];
} {
const entryRootId = (entry: ShareBundleManifest['entries'][number]) =>
getShareBundleEntryRootId(manifest, entry);
const rootOrder =
manifest.rootLogicalIds && manifest.rootLogicalIds.length > 0
? manifest.rootLogicalIds
: Array.from(new Set(manifest.entries.map(entryRootId)));
const sortedEntries: ShareBundleManifest['entries'] = [];
for (const rootId of rootOrder) {
const rootEntries = manifest.entries
.filter((entry) => entryRootId(entry) === rootId)
.sort((a, b) => a.versionNumber - b.versionNumber);
sortedEntries.push(...rootEntries);
}
return { rootOrder, sortedEntries };
}
export async function loadShareBundleEntries(
blob: Blob
): Promise<{
manifest: ShareBundleManifest;
rootOrder: string[];
sortedEntries: ShareBundleManifest['entries'];
files: File[];
} | null> {
const zip = await JSZip.loadAsync(blob);
const manifestEntry = zip.file(MANIFEST_FILENAME);
if (!manifestEntry) {
return null;
}
const manifestText = await manifestEntry.async('text');
const manifest = JSON.parse(manifestText) as ShareBundleManifest;
const { rootOrder, sortedEntries } = resolveShareBundleOrder(manifest);
const files: File[] = [];
for (const entry of sortedEntries) {
const zipEntry = zip.file(entry.filePath);
if (!zipEntry) {
throw new Error(`Missing file entry ${entry.filePath}`);
}
const fileBlob = await zipEntry.async('blob');
files.push(
new File([fileBlob], entry.name, {
type: entry.type,
lastModified: entry.lastModified,
})
);
}
return { manifest, rootOrder, sortedEntries, files };
}
export async function extractLatestFilesFromBundle(
blob: Blob,
filename: string,
contentType: string
): Promise<File[]> {
if (!isZipBundle(contentType, filename)) {
return [new File([blob], filename, { type: contentType || blob.type })];
}
const bundle = await loadShareBundleEntries(blob);
if (!bundle) {
return [new File([blob], filename, { type: contentType || blob.type })];
}
const { manifest, rootOrder, sortedEntries, files } = bundle;
const latestByRoot = new Map<string, File>();
for (let i = 0; i < sortedEntries.length; i += 1) {
const entry = sortedEntries[i];
latestByRoot.set(getShareBundleEntryRootId(manifest, entry), files[i]);
}
const latestFiles = rootOrder
.map((rootId) => latestByRoot.get(rootId))
.filter((file): file is File => Boolean(file));
if (latestFiles.length > 0) {
return latestFiles;
}
return [new File([blob], filename, { type: contentType || blob.type })];
}
+6
View File
@@ -28,4 +28,10 @@ export const Z_INDEX_SIGN_IN_MODAL = 9000;
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
export const Z_INDEX_TOAST = 10001;
// Signature preview overlays inside the PDF viewer
export const Z_INDEX_SIGNATURE_DRAG_BLOCKER = 999;
export const Z_INDEX_SIGNATURE_OVERLAY = 1000;
export const Z_INDEX_SIGNATURE_OVERLAY_HANDLE = 1001;
export const Z_INDEX_SIGNATURE_OVERLAY_DELETE = 1002;
+7 -17
View File
@@ -221,14 +221,9 @@ export const mantineTheme = createTheme({
},
option: {
color: 'var(--text-primary)',
'&[data-hovered]': {
backgroundColor: 'var(--hover-bg)',
},
'&[data-selected]': {
backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-900)',
},
},
'--combobox-option-hover': 'var(--hover-bg)',
'--combobox-option-selected': 'var(--color-primary-100)',
} as any,
},
},
@@ -254,14 +249,9 @@ export const mantineTheme = createTheme({
},
option: {
color: 'var(--text-primary)',
'&[data-hovered]': {
backgroundColor: 'var(--hover-bg)',
},
'&[data-selected]': {
backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-900)',
},
},
'--combobox-option-hover': 'var(--hover-bg)',
'--combobox-option-selected': 'var(--color-primary-100)',
} as any,
},
},
Tooltip: {
@@ -356,7 +346,7 @@ export const mantineTheme = createTheme({
},
control: {
color: 'var(--text-secondary)',
'&[data-active]': {
'&[dataActive]': {
backgroundColor: 'var(--bg-surface)',
color: 'var(--text-primary)',
boxShadow: 'var(--shadow-sm)',
+5
View File
@@ -44,6 +44,11 @@ export interface AppConfig {
isNewUser?: boolean;
defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean;
storageEnabled?: boolean;
storageSharingEnabled?: boolean;
storageShareLinksEnabled?: boolean;
storageShareEmailEnabled?: boolean;
storageGroupSigningEnabled?: boolean;
hideDisabledToolsGoogleDrive?: boolean;
hideDisabledToolsMobileQRScanner?: boolean;
googleDriveEnabled?: boolean;
+19
View File
@@ -35,4 +35,23 @@ export interface BaseFileMetadata {
versionNumber: number; // Version number in chain
parentFileId?: FileId; // Immediate parent file ID
toolHistory?: ToolOperation[]; // Tool chain for history tracking
// Remote storage tracking
remoteStorageId?: number; // Server-side storage ID for this file chain
remoteStorageUpdatedAt?: number; // Timestamp when chain was last uploaded
remoteOwnerUsername?: string; // Server-side owner username (if known)
remoteOwnedByCurrentUser?: boolean; // Ownership flag for server files
remoteAccessRole?: string; // Access role for shared server files
remoteSharedViaLink?: boolean; // True when imported from a share link
remoteHasShareLinks?: boolean; // True when owner has shared this file
remoteShareToken?: string; // Share token when file is from a share link
}
/**
* Minimal file shape used by signing workflow components.
* Both StirlingFile (extends File) and StirlingFileStub are assignable to this.
*/
export interface FileState {
name: string;
size: number;
}
+12
View File
@@ -289,6 +289,18 @@ export type FileContextAction =
export interface FileContextActions {
// File management - lightweight actions only
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
addFilesWithOptions: (
files: File[],
options?: {
insertAfterPageId?: string;
selectFiles?: boolean;
autoUnzip?: boolean;
autoUnzipFileLimit?: number;
skipAutoUnzip?: boolean;
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>;
allowDuplicates?: boolean;
}
) => Promise<StirlingFile[]>;
addStirlingFileStubs: (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
+80
View File
@@ -0,0 +1,80 @@
export interface WetSignatureMetadata {
type: 'canvas' | 'image' | 'text';
data: string; // Base64-encoded image data or text content
page: number; // Zero-indexed page number
x: number; // X coordinate in PDF points
y: number; // Y coordinate in PDF points (top-left origin)
width: number; // Width in PDF points
height: number; // Height in PDF points
}
export interface SessionSummary {
sessionId: string;
documentName: string;
createdAt: string;
participantCount: number;
signedCount: number;
finalized: boolean;
}
export interface SessionDetail {
sessionId: string;
documentName: string;
ownerEmail: string;
message: string;
dueDate: string;
createdAt: string;
updatedAt: string;
finalized: boolean;
participants: ParticipantInfo[];
}
export interface ParticipantInfo {
id: number;
userId: number;
email: string;
name: string;
status: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED';
lastUpdated: string;
// Signature appearance settings (owner-controlled)
showSignature?: boolean;
pageNumber?: number;
reason?: string;
location?: string;
showLogo?: boolean;
// Wet signatures (visual annotations placed by participant)
wetSignatures?: WetSignatureMetadata[];
}
export interface UserSummary {
userId: number;
username: string;
displayName: string;
teamName: string | null;
enabled: boolean;
}
export interface SignRequestSummary {
sessionId: string;
documentName: string;
ownerUsername: string;
createdAt: string;
dueDate: string;
myStatus: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED';
}
export interface SignRequestDetail {
sessionId: string;
documentName: string;
ownerUsername: string;
message: string;
dueDate: string;
createdAt: string;
myStatus: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED';
// Signature appearance settings (read-only, configured by owner)
showSignature?: boolean;
pageNumber?: number;
reason?: string;
location?: string;
showLogo?: boolean;
}