mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+95
-4
@@ -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 && (
|
||||
<>
|
||||
|
||||
+11
-1
@@ -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">
|
||||
|
||||
+43
-1
@@ -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' }}>
|
||||
|
||||
+13
-53
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user