photo scan V2 (#5255)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2025-12-30 18:55:56 +00:00
committed by GitHub
parent 8f1af5f967
commit 70fc6348f3
38 changed files with 4394 additions and 2780 deletions
+39 -6
View File
@@ -1,8 +1,12 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -13,15 +17,44 @@ import "@app/styles/index.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
@@ -1,12 +1,14 @@
import React from 'react';
import React, { useState } from 'react';
import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import CloudIcon from '@mui/icons-material/Cloud';
import PhonelinkIcon from '@mui/icons-material/Phonelink';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -15,12 +17,13 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const UploadIcon = icons.upload;
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const handleGoogleDriveClick = async () => {
try {
@@ -33,6 +36,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
}
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = (files: File[]) => {
if (files.length > 0) {
onNewFilesSelect(files);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
@@ -105,24 +118,59 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<PhonelinkIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleMobileUploadClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)'
}
}
}}
>
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
</Button>
</>
);
if (horizontal) {
return (
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<>
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
}
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<>
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
};
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Container, Button, Group, useMantineColorScheme, ActionIcon, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
@@ -11,6 +11,9 @@ import { useLogoVariant } from '@app/hooks/useLogoVariant';
import { useFileManager } from '@app/hooks/useFileManager';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -24,8 +27,11 @@ const LandingPage = () => {
const { wordmark } = useLogoAssets();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const [mobileUploadModalOpen, setMobileUploadModalOpen] = React.useState(false);
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const { config } = useAppConfig();
const isMobile = useIsMobile();
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
@@ -48,6 +54,16 @@ const LandingPage = () => {
event.target.value = '';
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = async (files: File[]) => {
if (files.length > 0) {
await addFiles(files);
}
};
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
@@ -202,32 +218,72 @@ const LandingPage = () => {
</span>
)}
</Button>
{config?.enableMobileScanner && !isMobile && (
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--accent-interactive)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
paddingLeft: '0.5rem',
paddingRight: '0.5rem',
}}
>
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
</ActionIcon>
</Tooltip>
)}
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
<>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: 'calc(100% - 38px - 0.6rem)',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
{config?.enableMobileScanner && !isMobile && (
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--accent-interactive)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
paddingLeft: '0.5rem',
paddingRight: '0.5rem',
}}
>
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
</ActionIcon>
</Tooltip>
)}
</>
)}
</div>
@@ -251,6 +307,11 @@ const LandingPage = () => {
</span>
</div>
</Dropzone>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</Container>
);
};
@@ -0,0 +1,332 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core';
import { QRCodeSVG } from 'qrcode.react';
import { useTranslation } from 'react-i18next';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { withBasePath } from '@app/constants/app';
interface MobileUploadModalProps {
opened: boolean;
onClose: () => void;
onFilesReceived: (files: File[]) => void;
}
// Generate a cryptographically secure UUID v4-like session ID
function generateSessionId(): string {
// Use Web Crypto API for cryptographically secure random values
const cryptoObj = typeof crypto !== 'undefined' ? crypto : (window as any).crypto;
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
// Set version (4) and variant bits per RFC 4122
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
// Convert bytes to hex string in UUID format
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join(''),
].join('-');
}
// If Web Crypto is not available, fail fast rather than using insecure randomness
console.error('Web Crypto API not available. Cannot generate secure session ID.');
throw new Error('Web Crypto API not available. Cannot generate secure session ID.');
}
interface SessionInfo {
sessionId: string;
createdAt: number;
expiresAt: number;
timeoutMs: number;
}
/**
* MobileUploadModal
*
* Displays a QR code that mobile devices can scan to upload files via backend server.
* Files are temporarily stored on server and retrieved by desktop.
*/
export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const [sessionId, setSessionId] = useState(() => generateSessionId());
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
const [filesReceived, setFilesReceived] = useState(0);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const [showExpiryWarning, setShowExpiryWarning] = useState(false);
const pollIntervalRef = useRef<number | null>(null);
const timerIntervalRef = useRef<number | null>(null);
const processedFiles = useRef<Set<string>>(new Set());
// Use configured frontendUrl if set, otherwise use current origin
// Combine with base path and mobile-scanner route
const frontendUrl = config?.frontendUrl || window.location.origin;
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
// Create session on backend
const createSession = useCallback(async (newSessionId: string) => {
try {
const response = await fetch(`/api/v1/mobile-scanner/create-session/${newSessionId}`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to create session');
}
const data = await response.json();
setSessionInfo(data);
setError(null);
console.log('Session created:', data);
} catch (err) {
console.error('Failed to create session:', err);
setError(t('mobileUpload.sessionCreateError', 'Failed to create session'));
}
}, [t]);
// Regenerate session (when expired or warned)
const regenerateSession = useCallback(() => {
const newSessionId = generateSessionId();
setSessionId(newSessionId);
setShowExpiryWarning(false);
setFilesReceived(0);
processedFiles.current.clear();
createSession(newSessionId);
}, [createSession]);
const pollForFiles = useCallback(async () => {
if (!opened) return;
try {
const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`);
if (!response.ok) {
throw new Error('Failed to check for files');
}
const data = await response.json();
const files = data.files || [];
// Download only files we haven't processed yet
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
if (newFiles.length > 0) {
for (const fileMetadata of newFiles) {
try {
const downloadResponse = await fetch(
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`
);
if (downloadResponse.ok) {
const blob = await downloadResponse.blob();
const file = new File([blob], fileMetadata.filename, {
type: fileMetadata.contentType || 'image/jpeg'
});
processedFiles.current.add(fileMetadata.filename);
setFilesReceived((prev) => prev + 1);
onFilesReceived([file]);
}
} catch (err) {
console.error('Failed to download file:', fileMetadata.filename, err);
}
}
// Delete the entire session immediately after downloading all files
// This ensures files are only on server for ~1 second
try {
await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' });
console.log('Session cleaned up after file download');
} catch (cleanupErr) {
console.warn('Failed to cleanup session after download:', cleanupErr);
}
}
} catch (err) {
console.error('Error polling for files:', err);
setError(t('mobileUpload.pollingError', 'Error checking for files'));
}
}, [opened, sessionId, onFilesReceived, t]);
// Create session when modal opens
useEffect(() => {
if (opened) {
createSession(sessionId);
setFilesReceived(0);
setError(null);
setShowExpiryWarning(false);
processedFiles.current.clear();
} else {
// Clean up session when modal closes
if (sessionId) {
fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' })
.catch(err => console.warn('Failed to cleanup session on close:', err));
}
}
}, [opened]); // Only run when opened changes
// Start polling for files when modal opens
useEffect(() => {
if (opened && sessionInfo) {
// Poll every 2 seconds
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
// Initial poll
pollForFiles();
} else {
// Stop polling when modal closes
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [opened, sessionInfo, pollForFiles]);
// Session timeout timer
useEffect(() => {
if (!opened || !sessionInfo) return;
const updateTimer = () => {
const now = Date.now();
const remaining = sessionInfo.expiresAt - now;
if (remaining <= 0) {
// Session expired - regenerate
setShowExpiryWarning(false);
regenerateSession();
} else if (remaining <= 60000 && !showExpiryWarning) {
// Less than 1 minute remaining - show warning
setShowExpiryWarning(true);
}
setTimeRemaining(Math.max(0, remaining));
};
// Update immediately
updateTimer();
// Update every second
timerIntervalRef.current = window.setInterval(updateTimer, 1000);
return () => {
if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current);
}
};
}, [opened, sessionInfo, showExpiryWarning, regenerateSession]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t('mobileUpload.title', 'Upload from Mobile')}
centered
size="md"
radius="lg"
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
overlayProps={{ opacity: 0.35, blur: 2 }}
styles={{
body: {
paddingTop: '1.5rem',
},
}}
>
<Stack gap="md">
<Alert
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
color="blue"
variant="light"
>
<Text size="sm">
{t(
'mobileUpload.description',
'Scan this QR code with your mobile device to upload photos directly to this page.'
)}
</Text>
</Alert>
{showExpiryWarning && timeRemaining !== null && (
<Alert
icon={<WarningRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')}
color="orange"
>
<Text size="sm">
{t(
'mobileUpload.expiryWarningMessage',
'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.',
{ seconds: Math.ceil(timeRemaining / 1000) }
)}
</Text>
</Alert>
)}
{error && (
<Alert
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.error', 'Connection Error')}
color="red"
>
<Text size="sm">{error}</Text>
</Alert>
)}
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
</Box>
{filesReceived > 0 && (
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
</Badge>
)}
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
{t(
'mobileUpload.instructions',
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
)}
</Text>
<Text
size="xs"
c="dimmed"
style={{
wordBreak: 'break-all',
textAlign: 'center',
fontFamily: 'monospace',
}}
>
{mobileUrl}
</Text>
</Box>
</Stack>
</Modal>
);
}
@@ -326,20 +326,22 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
);
return (
<Tooltip
position="right"
arrow
offset={8}
open={tooltipOpen}
manualCloseOnly={manualCloseOnly}
showCloseButton={showCloseButton}
closeOnOutside={false}
openOnFocus={false}
content={toursTooltipContent}
onOpenChange={handleTooltipOpenChange}
>
{helpButtonNode}
</Tooltip>
<React.Fragment key={buttonConfig.id}>
<Tooltip
position="right"
arrow
offset={8}
open={tooltipOpen}
manualCloseOnly={manualCloseOnly}
showCloseButton={showCloseButton}
closeOnOutside={false}
openOnFocus={false}
content={toursTooltipContent}
onOpenChange={handleTooltipOpenChange}
>
{helpButtonNode}
</Tooltip>
</React.Fragment>
);
}
@@ -17,7 +17,7 @@ export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps)
return (
<Alert
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
icon={<LocalIcon icon="lock" width={20} height={20} />}
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
color="blue"
variant="light"
@@ -193,7 +193,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
size="sm"
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
onClick={() => setUpdateModalOpened(true)}
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
leftSection={<LocalIcon icon="system-update-alt-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.viewDetails', 'View Details')}
</Button>
@@ -28,12 +28,12 @@ function getDefaultIconName(t: ToastInstance): string {
case 'success':
return 'check-circle-rounded';
case 'error':
return 'close-rounded';
return 'cancel';
case 'warning':
return 'warning-rounded';
return 'warning';
case 'neutral':
default:
return 'info-rounded';
return 'info';
}
}
@@ -63,7 +63,7 @@ export default function ToastRenderer() {
{/* Icon */}
<div className="toast-icon">
{t.icon ?? (
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
<LocalIcon icon={getDefaultIconName(t)} width={20} height={20} />
)}
</div>
@@ -86,7 +86,7 @@ export default function ToastRenderer() {
}}
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
>
<LocalIcon icon="material-symbols:expand-more-rounded" />
<LocalIcon icon="expand-more-rounded" />
</button>
)}
<button
@@ -94,7 +94,7 @@ export default function ToastRenderer() {
onClick={() => dismiss(t.id)}
className="toast-button"
>
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
<LocalIcon icon="close" width={20} height={20} />
</button>
</div>
</div>
@@ -43,7 +43,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
component="label"
htmlFor="attachments-input"
disabled={disabled}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
>
{parameters.attachments?.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
@@ -234,7 +234,7 @@ export const SavedSignaturesSection = ({
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:person-rounded" width={18} height={18} />
<LocalIcon icon="person-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.personalHeading', 'Personal Signatures')}
</Text>
@@ -257,7 +257,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activePersonalIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
@@ -265,7 +265,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
@@ -284,7 +284,7 @@ export const SavedSignaturesSection = ({
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
@@ -294,7 +294,7 @@ export const SavedSignaturesSection = ({
onClick={() => onDeleteSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
@@ -317,7 +317,7 @@ export const SavedSignaturesSection = ({
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:groups-rounded" width={18} height={18} />
<LocalIcon icon="groups-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.sharedHeading', 'Shared Signatures')}
</Text>
@@ -340,7 +340,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeSharedIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
@@ -348,7 +348,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
@@ -367,7 +367,7 @@ export const SavedSignaturesSection = ({
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
{isAdmin && (
<Tooltip label={translate('saved.delete', 'Remove')}>
@@ -378,7 +378,7 @@ export const SavedSignaturesSection = ({
onClick={() => onDeleteSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
)}
@@ -424,7 +424,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeLocalStorageIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
@@ -432,7 +432,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
@@ -451,7 +451,7 @@ export const SavedSignaturesSection = ({
onClick={() => onUseSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
@@ -461,7 +461,7 @@ export const SavedSignaturesSection = ({
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
@@ -370,7 +370,7 @@ const SignSettings = ({
isReady,
onClick,
'personal',
'material-symbols:person-rounded',
'person-rounded',
translate('saved.savePersonal', 'Save Personal')
);
@@ -379,7 +379,7 @@ const SignSettings = ({
isReady,
onClick,
'shared',
'material-symbols:groups-rounded',
'groups-rounded',
translate('saved.saveShared', 'Save Shared')
);
@@ -488,7 +488,7 @@ const SignSettings = ({
const handleCanvasSignatureChange = useCallback((data: string | null) => {
const nextValue = data ?? undefined;
setCanvasSignatureData(prevData => {
// Reset pause state and trigger placement for signature changes
// Reset pause-rounded state and trigger placement for signature changes
// (onDrawingComplete handles initial activation)
if (prevData && prevData !== nextValue && nextValue) {
setPlacementManuallyPaused(false);
@@ -899,7 +899,7 @@ const SignSettings = ({
color: isPlacementMode ? 'blue' : 'teal',
title: isPlacementMode
? translate('instructions.title', 'How to add your signature')
: translate('instructions.paused', 'Placement paused'),
: translate('instructions.pause-roundedd', 'Placement pause-roundedd'),
message: isPlacementMode
? placementInstructions()
: translate('instructions.resumeHint', 'Resume placement to click and add your signature.'),
@@ -920,7 +920,7 @@ const SignSettings = ({
onActivateSignaturePlacement?.();
};
// Handle Escape key to toggle pause/resume
// Handle Escape key to toggle pause-rounded/resume
useEffect(() => {
if (!isCurrentTypeReady) return;
@@ -943,11 +943,11 @@ const SignSettings = ({
onActivateSignaturePlacement || onDeactivateSignature
? isPlacementMode
? (
<Tooltip label={translate('mode.pause', 'Pause placement')}>
<Tooltip label={translate('mode.pause-rounded', 'Pause placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={translate('mode.pause', 'Pause placement')}
aria-label={translate('mode.pause-rounded', 'Pause placement')}
onClick={handlePausePlacement}
disabled={disabled || !onDeactivateSignature}
style={{
@@ -958,9 +958,9 @@ const SignSettings = ({
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
<LocalIcon icon="pause-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{translate('mode.pause', 'Pause placement')}
{translate('mode.pause-rounded', 'Pause placement')}
</Text>
</ActionIcon>
</Tooltip>
@@ -981,7 +981,7 @@ const SignSettings = ({
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
<LocalIcon icon="play-arrow-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{translate('mode.resume', 'Resume placement')}
</Text>
@@ -18,6 +18,7 @@ export interface AppConfig {
baseUrl?: string;
contextPath?: string;
serverPort?: number;
frontendUrl?: string;
appNameNavbar?: string;
languages?: string[];
defaultLocale?: string;
@@ -43,6 +44,7 @@ export interface AppConfig {
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
enableMobileScanner?: boolean;
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
@@ -224,7 +224,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false, //TODO make support Sign
},
addText: {
icon: <LocalIcon icon="material-symbols:text-fields-rounded" width="1.5rem" height="1.5rem" />,
icon: <LocalIcon icon="text-fields-rounded" width="1.5rem" height="1.5rem" />,
name: t('home.addText.title', 'Add Text'),
component: AddText,
description: t('home.addText.desc', 'Add custom text anywhere in your PDF'),
File diff suppressed because it is too large Load Diff
@@ -1250,7 +1250,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:touch-app-rounded" width={20} height={20} />
<LocalIcon icon="touch-app-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('annotation.selectAndMove', 'Select and Edit')}
</Text>
+46 -14
View File
@@ -3,11 +3,14 @@ import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -19,24 +22,53 @@ import "@app/styles/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
@@ -1,7 +1,9 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor } from '@mantine/core';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
@@ -43,10 +45,12 @@ interface ConnectionsSettingsData {
from?: string;
};
ssoAutoLogin?: boolean;
enableMobileScanner?: boolean;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
@@ -65,14 +69,19 @@ export default function AdminConnectionsSection() {
const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium');
const premiumData = premiumResponse.data || {};
// Fetch system settings for enableMobileScanner
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
const systemData = systemResponse.data || {};
const result: any = {
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {},
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
enableMobileScanner: systemData.enableMobileScanner || false
};
// Merge pending blocks from all three endpoints
// Merge pending blocks from all four endpoints
const pendingBlock: any = {};
if (securityData._pending?.oauth2) {
pendingBlock.oauth2 = securityData._pending.oauth2;
@@ -86,6 +95,9 @@ export default function AdminConnectionsSection() {
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
}
if (systemData._pending?.enableMobileScanner !== undefined) {
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
@@ -331,6 +343,38 @@ export default function AdminConnectionsSection() {
}
};
const handleMobileScannerSave = async (newValue: boolean) => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
const deltaSettings = {
'system.enableMobileScanner': newValue
};
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
@@ -356,7 +400,15 @@ export default function AdminConnectionsSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -383,6 +435,45 @@ export default function AdminConnectionsSection() {
</Stack>
</Paper>
{/* Mobile Scanner (QR Code) Upload */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group gap="xs" align="center">
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">{t('admin.settings.connections.mobileScanner.label', 'Mobile Phone Upload')}</Text>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.mobileScanner.enable', 'Enable QR Code Upload')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.mobileScanner.description', 'Allow users to upload files from mobile devices by scanning a QR code')}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t('admin.settings.connections.mobileScanner.note', 'Note: Requires Frontend URL to be configured. ')}
<Anchor href="#" onClick={(e) => { e.preventDefault(); navigate('/settings/adminGeneral#frontendUrl'); }} c="orange" td="underline">
{t('admin.settings.connections.mobileScanner.link', 'Configure in System Settings')}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableMobileScanner || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
const newValue = e.target.checked;
setSettings({ ...settings, enableMobileScanner: newValue });
handleMobileScannerSave(newValue);
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableMobileScanner')} />
</Group>
</div>
</Stack>
</Paper>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
@@ -21,6 +22,7 @@ interface FeaturesSettingsData {
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
@@ -118,7 +120,15 @@ export default function AdminFeaturesSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate.label', 'Server Certificate')}</Text>
<Badge color="blue" size="sm">PRO</Badge>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<Text size="xs" c="dimmed">
@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
@@ -26,6 +27,7 @@ interface GeneralSettingsData {
showUpdateOnlyAdmin?: boolean;
customHTMLFiles?: boolean;
fileUploadLimit?: string;
frontendUrl?: string;
};
customPaths?: {
pipeline?: {
@@ -47,6 +49,8 @@ interface GeneralSettingsData {
export default function AdminGeneralSection() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { preferences, updatePreference } = usePreferences();
@@ -141,6 +145,7 @@ export default function AdminGeneralSection() {
'system.showUpdateOnlyAdmin': settings.system?.showUpdateOnlyAdmin,
'system.customHTMLFiles': settings.system?.customHTMLFiles,
'system.fileUploadLimit': settings.system?.fileUploadLimit,
'system.frontendUrl': settings.system?.frontendUrl,
// Premium custom metadata
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
@@ -228,6 +233,19 @@ export default function AdminGeneralSection() {
};
}, [setIsDirty]);
// Handle hash navigation for deep linking to specific fields
useEffect(() => {
if (location.hash && !loading) {
const elementId = location.hash.substring(1); // Remove the #
const element = document.getElementById(elementId);
if (element) {
setTimeout(() => {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
}, [location.hash, loading]);
const handleDiscard = useCallback(() => {
if (originalSettingsSnapshot) {
try {
@@ -441,6 +459,22 @@ export default function AdminGeneralSection() {
/>
</div>
<div id="frontendUrl">
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.frontendUrl.label', 'Frontend URL')}</span>
<PendingBadge show={isFieldPending('system.frontendUrl')} />
</Group>
}
description={t('admin.settings.general.frontendUrl.description', 'Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL.')}
value={settings.system?.frontendUrl || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, frontendUrl: e.target.value } })}
placeholder="https://pdf.example.com"
disabled={!loginEnabled}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text>
@@ -499,7 +533,15 @@ export default function AdminGeneralSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata.label', 'Custom Metadata')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput, Anchor } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
@@ -17,7 +18,6 @@ interface MailSettingsData {
username?: string;
password?: string;
from?: string;
frontendUrl?: string;
}
interface ApiResponseWithPending<T> {
@@ -25,10 +25,10 @@ interface ApiResponseWithPending<T> {
}
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
type SystemApiResponse = { frontendUrl?: string } & ApiResponseWithPending<{ frontendUrl?: string }>;
export default function AdminMailSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -42,44 +42,13 @@ export default function AdminMailSection() {
} = useAdminSettings<MailSettingsData>({
sectionName: 'mail',
fetchTransformer: async () => {
const [mailResponse, systemResponse] = await Promise.all([
apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail'),
apiClient.get<SystemApiResponse>('/api/v1/admin/settings/section/system')
]);
const mail = mailResponse.data || {};
const system = systemResponse.data || {};
const result: MailSettingsData & ApiResponseWithPending<MailSettingsData> = {
...mail,
frontendUrl: system.frontendUrl || ''
};
// Merge pending blocks from both endpoints
const pendingBlock: Partial<MailSettingsData> = {};
if (mail._pending) {
Object.assign(pendingBlock, mail._pending);
}
if (system._pending?.frontendUrl !== undefined) {
pendingBlock.frontendUrl = system._pending.frontendUrl;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
const mailResponse = await apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail');
return mailResponse.data || {};
},
saveTransformer: (settings) => {
const { frontendUrl, ...mailSettings } = settings;
const deltaSettings: Record<string, any> = {
'system.frontendUrl': frontendUrl
};
return {
sectionData: mailSettings,
deltaSettings
sectionData: settings,
deltaSettings: {}
};
}
});
@@ -142,6 +111,12 @@ export default function AdminMailSection() {
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t('admin.settings.mail.frontendUrlNote.note', 'Note: Requires Frontend URL to be configured. ')}
<Anchor href="#" onClick={(e) => { e.preventDefault(); navigate('/settings/adminGeneral#frontendUrl'); }} c="orange" td="underline">
{t('admin.settings.mail.frontendUrlNote.link', 'Configure in System Settings')}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
@@ -226,21 +201,6 @@ export default function AdminMailSection() {
placeholder="[email protected]"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.frontendUrl.label', 'Frontend URL')}</span>
<PendingBadge show={isFieldPending('frontendUrl')} />
</Group>
}
description={t('admin.settings.mail.frontendUrl.description', 'Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL.')}
value={settings.frontendUrl || ''}
onChange={(e) => setSettings({ ...settings, frontendUrl: e.target.value })}
placeholder="https://pdf.example.com"
/>
</div>
</Stack>
</Paper>