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
@@ -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>