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