mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
fileshare (#5414)
Co-authored-by: ConnorYoh <[email protected]> Co-authored-by: Connor Yoh <[email protected]> Co-authored-by: EthanHealy01 <[email protected]> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ConnorYoh
Connor Yoh
EthanHealy01
Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent
47cad0a131
commit
28613caf8a
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user