From 0f7ee5c5b0bc02b22845c4432c1d6fd22beb39cd Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:20:20 +0000 Subject: [PATCH] settings menu reworks (#5864) --- .../software/common/util/GeneralUtils.java | 30 ++ .../api/AdminSettingsController.java | 19 +- build.gradle | 7 +- .../public/locales/en-GB/translation.toml | 13 + .../shared/config/SettingsStickyFooter.tsx | 42 ++ .../config/configSections/ProviderCard.tsx | 54 +- frontend/src/core/hooks/useSettingsDirty.ts | 79 +++ .../configSections/AdminAdvancedSection.tsx | 40 +- .../AdminConnectionsSection.tsx | 473 ++++++------------ .../configSections/AdminDatabaseSection.tsx | 42 +- .../configSections/AdminEndpointsSection.tsx | 72 +-- .../configSections/AdminFeaturesSection.tsx | 41 +- .../configSections/AdminGeneralSection.tsx | 120 ++--- .../configSections/AdminLegalSection.tsx | 32 +- .../configSections/AdminMailSection.tsx | 40 +- .../configSections/AdminPremiumSection.tsx | 32 +- .../configSections/AdminPrivacySection.tsx | 41 +- .../configSections/AdminSecuritySection.tsx | 43 +- 18 files changed, 640 insertions(+), 580 deletions(-) create mode 100644 frontend/src/core/components/shared/config/SettingsStickyFooter.tsx create mode 100644 frontend/src/core/hooks/useSettingsDirty.ts diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 144759e559..a0086ea346 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -873,6 +873,36 @@ public class GeneralUtils { settingsYaml.saveOverride(settingsPath); } + /** + * Updates multiple settings in a single transaction. This ensures that nested settings (e.g., + * oauth2.client.google.*) don't lose sibling values when partial updates are made. + * + *

Instead of multiple read-update-write cycles (which could cause race conditions), this + * method loads the YAML once, applies all updates, and saves once. + * + * @param settingsMap Map of dotted-notation keys to values to update + * @throws IOException if file read/write fails + */ + public void updateSettingsTransactional(Map settingsMap) throws IOException { + if (settingsMap == null || settingsMap.isEmpty()) { + return; + } + + Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath()); + YamlHelper settingsYaml = new YamlHelper(settingsPath); + + // Apply all updates to the same YamlHelper instance + for (Map.Entry entry : settingsMap.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + String[] keyArray = key.split("\\."); + settingsYaml.updateValue(Arrays.asList(keyArray), value); + } + + // Save only once after all updates are applied + settingsYaml.saveOverride(settingsPath); + } + /* * Machine fingerprint generation with better error logging and fallbacks. * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index b3dff95243..98f61f57e9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -172,7 +172,7 @@ public class AdminSettingsController { .body(Map.of("error", "No settings provided to update")); } - int updatedCount = 0; + // Validate all settings first before applying any changes for (Map.Entry entry : settings.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); @@ -192,15 +192,18 @@ public class AdminSettingsController { return ResponseEntity.badRequest() .body(Map.of("error", HtmlUtils.htmlEscape(validationError))); } + } + // Apply all updates in a single transaction (load once, update all, save once) + // This ensures nested settings like oauth2.client.* don't lose sibling values + GeneralUtils.updateSettingsTransactional(settings); + + // Track all as pending changes + for (Map.Entry entry : settings.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); log.info("Admin updating setting: {} = {}", key, value); - GeneralUtils.saveKeyToSettings(key, value); - - // Track this as a pending change (convert null to empty string for - // ConcurrentHashMap) pendingChanges.put(key, value != null ? value : ""); - - updatedCount++; } return ResponseEntity.ok( @@ -209,7 +212,7 @@ public class AdminSettingsController { String.format( "Successfully updated %d setting(s). Changes will take effect on" + " application restart.", - updatedCount))); + settings.size()))); } catch (IOException e) { log.error("Failed to save settings to file: {}", e.getMessage(), e); diff --git a/build.gradle b/build.gradle index 377e768634..791893a395 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ ext { springSecuritySamlVersion = "7.0.2" openSamlVersion = "5.2.1" commonmarkVersion = "0.27.1" - googleJavaFormatVersion = "1.34.1" + googleJavaFormatVersion = "1.21.0" logback = "1.5.32" junitPlatformVersion = "1.12.2" modernJavaVersion = 21 @@ -177,9 +177,8 @@ subprojects { exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on' exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on' exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on' - // google-java-format 1.34+ requires Guava 33.x (ImmutableSortedMapFauxverideShim); - // force it here so Spotless's FeatureClassLoader resolves the correct version. - resolutionStrategy.force 'com.google.guava:guava:33.4.8-jre' + // Force a compatible Guava version for spotless + resolutionStrategy.force 'com.google.guava:guava:33.0.0-jre' // Security CVE fixes - hardcoded resolution strategy to ensure safe versions // Primary fixes via explicit dependencies in app/core/build.gradle: diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 8347b501bc..390693ad5a 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -652,6 +652,7 @@ description = "Configure external authentication providers like OAuth2 and SAML. disconnect = "Disconnect" disconnected = "Provider disconnected successfully" disconnectError = "Failed to disconnect provider" +documentation = "View documentation" imageResolutionFull = "Full (Original Size)" imageResolutionReduced = "Reduced (Max 1200px)" linkedServices = "Linked Services" @@ -965,6 +966,18 @@ label = "Default Locale" description = "Maximum file upload size (e.g., 100MB, 1GB)" label = "File Upload Limit" +[admin.settings.general.hideDisabledTools] +description = "Hide disabled tools from the interface" +label = "Hide Disabled Tools" + +[admin.settings.general.hideDisabledTools.googleDrive] +description = "Hide Google Drive button when not enabled" +label = "Hide Google Drive" + +[admin.settings.general.hideDisabledTools.mobileScanner] +description = "Hide mobile QR scanner button when not enabled" +label = "Hide Mobile Scanner" + [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." label = "Frontend URL" diff --git a/frontend/src/core/components/shared/config/SettingsStickyFooter.tsx b/frontend/src/core/components/shared/config/SettingsStickyFooter.tsx new file mode 100644 index 0000000000..0a533d57ee --- /dev/null +++ b/frontend/src/core/components/shared/config/SettingsStickyFooter.tsx @@ -0,0 +1,42 @@ +import { Button, Group, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +interface SettingsStickyFooterProps { + isDirty: boolean; + saving: boolean; + loginEnabled: boolean; + onSave: () => void; + onDiscard: () => void; +} + +export function SettingsStickyFooter({ + isDirty, + saving, + loginEnabled, + onSave, + onDiscard, +}: SettingsStickyFooterProps) { + const { t } = useTranslation(); + + if (!isDirty || !loginEnabled) { + return null; + } + + return ( +

+ + + {t('admin.settings.unsavedChanges.hint', 'You have unsaved changes')} + + + + + + +
+ ); +} diff --git a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx index 7877a53f09..570f9c3da3 100644 --- a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -23,7 +23,9 @@ interface ProviderCardProps { settings?: Record; onSave?: (settings: Record) => void; onDisconnect?: () => void; + onChange?: (settings: Record) => void; disabled?: boolean; + readOnly?: boolean; } export default function ProviderCard({ @@ -32,18 +34,19 @@ export default function ProviderCard({ settings = {}, onSave, onDisconnect, + onChange, disabled = false, + readOnly = false, }: ProviderCardProps) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const [localSettings, setLocalSettings] = useState>(settings); // Keep local settings in sync with incoming settings (values loaded from settings.yml) + // Update whenever parent settings change, whether expanded or not (important for Discard to work) useEffect(() => { - if (!expanded) { - setLocalSettings(settings); - } - }, [settings, expanded]); + setLocalSettings(settings); + }, [settings]); // Initialize local settings with defaults when opening an unconfigured provider const handleConnectToggle = () => { @@ -63,7 +66,12 @@ export default function ProviderCard({ const handleFieldChange = (key: string, value: any) => { if (disabled) return; // Block changes when disabled - setLocalSettings((prev) => ({ ...prev, [key]: value })); + const updated = { ...localSettings, [key]: value }; + setLocalSettings(updated); + // Notify parent of changes if onChange callback provided + if (onChange) { + onChange(updated); + } }; const handleSave = () => { @@ -225,22 +233,26 @@ export default function ProviderCard({ {provider.fields.map((field) => renderField(field))} - - {onDisconnect && ( - - )} - - + {!readOnly && (onSave || onDisconnect) && ( + + {onDisconnect && ( + + )} + {onSave && ( + + )} + + )} diff --git a/frontend/src/core/hooks/useSettingsDirty.ts b/frontend/src/core/hooks/useSettingsDirty.ts new file mode 100644 index 0000000000..20704677ef --- /dev/null +++ b/frontend/src/core/hooks/useSettingsDirty.ts @@ -0,0 +1,79 @@ +import { useEffect, useState, useRef } from 'react'; +import { useUnsavedChanges } from '@app/contexts/UnsavedChangesContext'; + +interface UseSettingsDirtyReturn { + isDirty: boolean; + resetToSnapshot: () => T; + markSaved: () => void; +} + +/** + * Hook for managing dirty state in settings sections + * Handles JSON snapshot comparison and UnsavedChangesContext integration + */ +export function useSettingsDirty(settings: T, loading: boolean): UseSettingsDirtyReturn { + const { setIsDirty } = useUnsavedChanges(); + const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState(''); + const [isDirty, setLocalIsDirty] = useState(false); + const isInitialLoad = useRef(true); + const justSavedRef = useRef(false); + + // Snapshot original settings after initial load OR after successful save (when refetch completes) + useEffect(() => { + if (loading || Object.keys(settings as Record).length === 0) return; + + // After initial load: set snapshot + if (isInitialLoad.current) { + setOriginalSettingsSnapshot(JSON.stringify(settings)); + isInitialLoad.current = false; + return; + } + + // After save: update snapshot to new server state so dirty tracking is accurate + if (justSavedRef.current) { + setOriginalSettingsSnapshot(JSON.stringify(settings)); + setLocalIsDirty(false); + setIsDirty(false); + justSavedRef.current = false; + } + }, [loading, settings, setIsDirty]); + + // Track dirty state by comparing current settings to snapshot + useEffect(() => { + if (!originalSettingsSnapshot || loading) return; + + const currentSnapshot = JSON.stringify(settings); + const dirty = currentSnapshot !== originalSettingsSnapshot; + setLocalIsDirty(dirty); + setIsDirty(dirty); + }, [settings, originalSettingsSnapshot, loading, setIsDirty]); + + // Clean up dirty state on unmount + useEffect(() => { + return () => { + setIsDirty(false); + }; + }, [setIsDirty]); + + const resetToSnapshot = (): T => { + if (originalSettingsSnapshot) { + try { + return JSON.parse(originalSettingsSnapshot) as T; + } catch (e) { + console.error('Failed to parse original settings:', e); + return settings; + } + } + return settings; + }; + + const markSaved = () => { + justSavedRef.current = true; + }; + + return { + isDirty, + resetToSnapshot, + markSaved, + }; +} diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx index 615ca92043..033abd326a 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx @@ -1,11 +1,13 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput, MultiSelect } 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'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import apiClient from '@app/services/apiClient'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; @@ -70,7 +72,7 @@ export default function AdminAdvancedSection() { isFieldPending, } = useAdminSettings({ sectionName: 'advanced', - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { const [systemResponse, processExecutorResponse] = await Promise.all([ apiClient.get('/api/v1/admin/settings/section/system'), apiClient.get('/api/v1/admin/settings/section/processExecutor') @@ -79,7 +81,7 @@ export default function AdminAdvancedSection() { const systemData = systemResponse.data || {}; const processExecutorData = processExecutorResponse.data || {}; - const result: any = { + const result: AdvancedSettingsData & { _pending?: Record } = { enableAlphaFunctionality: systemData.enableAlphaFunctionality || false, maxDPI: systemData.maxDPI || 0, enableUrlToPDF: systemData.enableUrlToPDF || false, @@ -99,7 +101,7 @@ export default function AdminAdvancedSection() { }; // Merge pending blocks from both endpoints - const pendingBlock: any = {}; + const pendingBlock: Record = {}; if (systemData._pending?.enableAlphaFunctionality !== undefined) { pendingBlock.enableAlphaFunctionality = systemData._pending.enableAlphaFunctionality; } @@ -334,11 +336,14 @@ export default function AdminAdvancedSection() { } }; + + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); const handleSave = async () => { if (!validateLoginEnabled()) { return; } try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -350,6 +355,11 @@ export default function AdminAdvancedSection() { } }; + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + const actualLoading = loginEnabled ? loading : false; if (actualLoading) { @@ -361,8 +371,9 @@ export default function AdminAdvancedSection() { } return ( - - +
+ +
{t('admin.settings.advanced.title', 'Advanced')} @@ -1092,12 +1103,15 @@ export default function AdminAdvancedSection() { - {/* Save Button */} - - - + + + {/* Restart Confirmation Modal */} - +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index c6c377539e..cdaa4e00b1 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor, Select, Collapse } from '@mantine/core'; @@ -7,7 +7,9 @@ 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'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex'; import ProviderCard from '@app/components/shared/config/configSections/ProviderCard'; import { Provider, useAllProviders } from '@app/components/shared/config/configSections/providerDefinitions'; @@ -81,13 +83,13 @@ interface ConnectionsSettingsData { export default function AdminConnectionsSection() { const { t } = useTranslation(); const navigate = useNavigate(); - const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); - const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { loginEnabled, getDisabledStyles } = useLoginRequired(); + const { restartModalOpened, closeRestartModal, restartServer } = useRestartServer(); const allProviders = useAllProviders(); const adminSettings = useAdminSettings({ sectionName: 'connections', - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { // Fetch security settings (oauth2, saml2) const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security'); const securityData = securityResponse.data || {}; @@ -108,7 +110,7 @@ export default function AdminConnectionsSection() { const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system'); const systemData = systemResponse.data || {}; - const result: any = { + const result: ConnectionsSettingsData & { _pending?: Record } = { oauth2: securityData.oauth2 || {}, saml2: securityData.saml2 || {}, mail: mailData || {}, @@ -121,51 +123,93 @@ export default function AdminConnectionsSection() { mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false }; - // Merge pending blocks from all four endpoints - const pendingBlock: any = {}; - if (securityData._pending?.oauth2) { - pendingBlock.oauth2 = securityData._pending.oauth2; - } - if (securityData._pending?.saml2) { - pendingBlock.saml2 = securityData._pending.saml2; - } - if (mailData._pending) { - pendingBlock.mail = mailData._pending; - } - if (telegramData._pending) { - pendingBlock.telegram = telegramData._pending; - } - if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) { - pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin; - } - if (systemData._pending?.enableMobileScanner !== undefined) { - pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner; - } - if (systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined) { - pendingBlock.mobileScannerConvertToPdf = systemData._pending.mobileScannerSettings.convertToPdf; - } - if (systemData._pending?.mobileScannerSettings?.imageResolution !== undefined) { - pendingBlock.mobileScannerImageResolution = systemData._pending.mobileScannerSettings.imageResolution; - } - if (systemData._pending?.mobileScannerSettings?.pageFormat !== undefined) { - pendingBlock.mobileScannerPageFormat = systemData._pending.mobileScannerSettings.pageFormat; - } - if (systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined) { - pendingBlock.mobileScannerStretchToFit = systemData._pending.mobileScannerSettings.stretchToFit; - } + // Merge pending blocks from all endpoints - initialize with defaults to avoid warnings + const pendingBlock: Record = { + oauth2: securityData._pending?.oauth2, + saml2: securityData._pending?.saml2, + mail: mailData._pending, + telegram: telegramData._pending, + ssoAutoLogin: premiumData._pending?.proFeatures?.ssoAutoLogin, + enableMobileScanner: systemData._pending?.enableMobileScanner, + mobileScannerConvertToPdf: systemData._pending?.mobileScannerSettings?.convertToPdf, + mobileScannerImageResolution: systemData._pending?.mobileScannerSettings?.imageResolution, + mobileScannerPageFormat: systemData._pending?.mobileScannerSettings?.pageFormat, + mobileScannerStretchToFit: systemData._pending?.mobileScannerSettings?.stretchToFit, + }; - if (Object.keys(pendingBlock).length > 0) { - result._pending = pendingBlock; - } + result._pending = pendingBlock; return result; }, - saveTransformer: () => { - // This section doesn't have a global save button - // Individual providers save through their own handlers + saveTransformer: (currentSettings: ConnectionsSettingsData) => { + const deltaSettings: Record = {}; + + // Build delta for oauth2 settings + if (currentSettings.oauth2) { + Object.keys(currentSettings.oauth2).forEach((key) => { + if (key !== 'client') { + deltaSettings[`security.oauth2.${key}`] = (currentSettings.oauth2 as Record)[key]; + } + }); + + // Build delta for specific OAuth2 providers + const oauth2Client = currentSettings.oauth2.client; + if (oauth2Client) { + Object.keys(oauth2Client).forEach((providerId) => { + const providerSettings = oauth2Client[providerId]; + Object.keys(providerSettings).forEach((key) => { + deltaSettings[`security.oauth2.client.${providerId}.${key}`] = providerSettings[key]; + }); + }); + } + } + + // Build delta for saml2 settings + if (currentSettings.saml2) { + Object.keys(currentSettings.saml2).forEach((key) => { + deltaSettings[`security.saml2.${key}`] = (currentSettings.saml2 as Record)[key]; + }); + } + + // Mail settings + if (currentSettings.mail) { + Object.keys(currentSettings.mail).forEach((key) => { + deltaSettings[`mail.${key}`] = (currentSettings.mail as Record)[key]; + }); + } + + // Telegram settings + if (currentSettings.telegram) { + Object.keys(currentSettings.telegram).forEach((key) => { + deltaSettings[`telegram.${key}`] = (currentSettings.telegram as Record)[key]; + }); + } + + // SSO Auto Login + if (currentSettings?.ssoAutoLogin !== undefined) { + deltaSettings['premium.proFeatures.ssoAutoLogin'] = currentSettings.ssoAutoLogin; + } + + // Mobile Scanner settings + if (currentSettings?.enableMobileScanner !== undefined) { + deltaSettings['system.enableMobileScanner'] = currentSettings.enableMobileScanner; + } + if (currentSettings?.mobileScannerConvertToPdf !== undefined) { + deltaSettings['system.mobileScannerSettings.convertToPdf'] = currentSettings.mobileScannerConvertToPdf; + } + if (currentSettings?.mobileScannerImageResolution !== undefined) { + deltaSettings['system.mobileScannerSettings.imageResolution'] = currentSettings.mobileScannerImageResolution; + } + if (currentSettings?.mobileScannerPageFormat !== undefined) { + deltaSettings['system.mobileScannerSettings.pageFormat'] = currentSettings.mobileScannerPageFormat; + } + if (currentSettings?.mobileScannerStretchToFit !== undefined) { + deltaSettings['system.mobileScannerSettings.stretchToFit'] = currentSettings.mobileScannerStretchToFit; + } + return { sectionData: {}, - deltaSettings: {} + deltaSettings }; } }); @@ -184,6 +228,26 @@ export default function AdminConnectionsSection() { } }, [loginEnabled, fetchSettings]); + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + + const handleSave = async () => { + markSaved(); + try { + await adminSettings.saveSettings(); + } catch (_error) { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.saveError', 'Failed to save settings'), + }); + } + }; + // Override loading state when login is disabled const actualLoading = loginEnabled ? loading : false; @@ -241,175 +305,6 @@ export default function AdminConnectionsSection() { return settings?.oauth2?.client?.[provider.id] || {}; }; - const handleProviderSave = async (provider: Provider, providerSettings: Record) => { - // Block save if login is disabled - if (!validateLoginEnabled()) { - return; - } - - try { - if (provider.id === 'smtp') { - // Mail settings use a different endpoint - const response = await apiClient.put('/api/v1/admin/settings/section/mail', providerSettings); - - if (response.status === 200) { - await fetchSettings(); // Refresh settings - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saveSuccess', 'Settings saved successfully'), - }); - showRestartModal(); - } else { - throw new Error('Failed to save'); - } - } else if (provider.id === 'telegram') { - const parseToNumberArray = (values: any) => - (Array.isArray(values) ? values : []) - .map((value) => Number(value)) - .filter((value) => !Number.isNaN(value)); - - const response = await apiClient.put('/api/v1/admin/settings/section/telegram', { - ...providerSettings, - allowUserIDs: parseToNumberArray(providerSettings.allowUserIDs), - allowChannelIDs: parseToNumberArray(providerSettings.allowChannelIDs), - processingTimeoutSeconds: providerSettings.processingTimeoutSeconds - ? Number(providerSettings.processingTimeoutSeconds) - : undefined, - pollingIntervalMillis: providerSettings.pollingIntervalMillis - ? Number(providerSettings.pollingIntervalMillis) - : undefined, - }); - - if (response.status === 200) { - await fetchSettings(); // Refresh settings - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saveSuccess', 'Settings saved successfully'), - }); - showRestartModal(); - } else { - throw new Error('Failed to save'); - } - } else { - // OAuth2/SAML2 use delta settings - const deltaSettings: Record = {}; - - if (provider.id === 'saml2') { - // SAML2 settings - Object.keys(providerSettings).forEach((key) => { - deltaSettings[`security.saml2.${key}`] = providerSettings[key]; - }); - } else if (provider.id === 'oauth2-generic') { - // Generic OAuth2 settings at root level - Object.keys(providerSettings).forEach((key) => { - deltaSettings[`security.oauth2.${key}`] = providerSettings[key]; - }); - } else { - // Specific OAuth2 provider (google, github, keycloak) - Object.keys(providerSettings).forEach((key) => { - deltaSettings[`security.oauth2.client.${provider.id}.${key}`] = providerSettings[key]; - }); - } - - const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); - - if (response.status === 200) { - await fetchSettings(); // Refresh settings - 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 handleProviderDisconnect = async (provider: Provider) => { - // Block disconnect if login is disabled - if (!validateLoginEnabled()) { - return; - } - - try { - if (provider.id === 'smtp') { - // Mail settings use a different endpoint - const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false }); - - if (response.status === 200) { - await fetchSettings(); - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'), - }); - showRestartModal(); - } else { - throw new Error('Failed to disconnect'); - } - } else if (provider.id === 'telegram') { - const response = await apiClient.put('/api/v1/admin/settings/section/telegram', { - enabled: false, - }); - - if (response.status === 200) { - await fetchSettings(); - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'), - }); - showRestartModal(); - } else { - throw new Error('Failed to disconnect'); - } - } else { - const deltaSettings: Record = {}; - - if (provider.id === 'saml2') { - deltaSettings['security.saml2.enabled'] = false; - } else if (provider.id === 'oauth2-generic') { - deltaSettings['security.oauth2.enabled'] = false; - } else { - // Clear all fields for specific OAuth2 provider - provider.fields.forEach((field) => { - deltaSettings[`security.oauth2.client.${provider.id}.${field.key}`] = ''; - }); - } - - const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); - - if (response.status === 200) { - await fetchSettings(); - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'), - }); - showRestartModal(); - } else { - throw new Error('Failed to disconnect'); - } - } - } catch (_error) { - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.connections.disconnectError', 'Failed to disconnect provider'), - }); - } - }; if (actualLoading) { return ( @@ -419,105 +314,38 @@ export default function AdminConnectionsSection() { ); } - const handleSSOAutoLoginSave = async () => { - // Block save if login is disabled - if (!validateLoginEnabled()) { - return; - } - - try { - const deltaSettings = { - 'premium.proFeatures.ssoAutoLogin': settings?.ssoAutoLogin - }; - - 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 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.settings.success', 'Settings saved successfully') - }); - fetchSettings(); - } - } catch (error) { - console.error('Failed to save mobile scanner setting:', error); - alert({ - alertType: 'error', - title: t('admin.settings.error', 'Failed to save settings') - }); - } - }; - - const handleMobileScannerSettingsSave = async (settingKey: string, newValue: string | boolean) => { - // Block save if login is disabled or mobile scanner is not enabled - if (!validateLoginEnabled() || !settings?.enableMobileScanner) { - return; - } - - try { - const deltaSettings = { - [`system.mobileScannerSettings.${settingKey}`]: 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 = allProviders.filter((p) => isProviderConfigured(p)); const availableProviders = allProviders.filter((p) => !isProviderConfigured(p)); + const updateProviderSettings = (provider: Provider, updatedSettings: Record) => { + if (provider.id === 'smtp') { + setSettings({ ...settings, mail: updatedSettings }); + } else if (provider.id === 'telegram') { + setSettings({ ...settings, telegram: updatedSettings }); + } else if (provider.id === 'saml2') { + setSettings({ ...settings, saml2: updatedSettings }); + } else if (provider.id === 'oauth2-generic') { + setSettings({ ...settings, oauth2: updatedSettings }); + } else { + // Specific OAuth2 provider + setSettings({ + ...settings, + oauth2: { + ...settings.oauth2, + client: { + ...settings.oauth2?.client, + [provider.id]: updatedSettings + } + } + }); + } + }; + return ( - - +
+ + {/* Header */}
@@ -561,7 +389,6 @@ export default function AdminConnectionsSection() { onChange={(e) => { if (!loginEnabled) return; // Block change when login disabled setSettings({ ...settings, ssoAutoLogin: e.target.checked }); - handleSSOAutoLoginSave(); }} disabled={!loginEnabled} styles={getDisabledStyles()} @@ -598,9 +425,7 @@ export default function AdminConnectionsSection() { 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); + setSettings({ ...settings, enableMobileScanner: e.target.checked }); }} disabled={!loginEnabled} styles={getDisabledStyles()} @@ -625,9 +450,7 @@ export default function AdminConnectionsSection() { checked={settings?.mobileScannerConvertToPdf !== false} onChange={(e) => { if (!loginEnabled) return; - const newValue = e.target.checked; - setSettings({ ...settings, mobileScannerConvertToPdf: newValue }); - handleMobileScannerSettingsSave('convertToPdf', newValue); + setSettings({ ...settings, mobileScannerConvertToPdf: e.target.checked }); }} disabled={!loginEnabled} /> @@ -652,7 +475,6 @@ export default function AdminConnectionsSection() { onChange={(value) => { if (!loginEnabled) return; setSettings({ ...settings, mobileScannerImageResolution: value || 'full' }); - handleMobileScannerSettingsSave('imageResolution', value || 'full'); }} data={[ { value: 'full', label: t('admin.settings.connections.imageResolutionFull', 'Full (Original Size)') }, @@ -680,7 +502,6 @@ export default function AdminConnectionsSection() { onChange={(value) => { if (!loginEnabled) return; setSettings({ ...settings, mobileScannerPageFormat: value || 'A4' }); - handleMobileScannerSettingsSave('pageFormat', value || 'A4'); }} data={[ { value: 'keep', label: t('admin.settings.connections.pageFormatKeep', 'Keep (Original Dimensions)') }, @@ -708,9 +529,7 @@ export default function AdminConnectionsSection() { checked={settings?.mobileScannerStretchToFit || false} onChange={(e) => { if (!loginEnabled) return; - const newValue = e.target.checked; - setSettings({ ...settings, mobileScannerStretchToFit: newValue }); - handleMobileScannerSettingsSave('stretchToFit', newValue); + setSettings({ ...settings, mobileScannerStretchToFit: e.target.checked }); }} disabled={!loginEnabled} /> @@ -738,8 +557,7 @@ export default function AdminConnectionsSection() { provider={provider} isConfigured={true} settings={getProviderSettings(provider)} - onSave={(providerSettings) => handleProviderSave(provider, providerSettings)} - onDisconnect={() => handleProviderDisconnect(provider)} + onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)} disabled={!loginEnabled} /> ))} @@ -764,7 +582,7 @@ export default function AdminConnectionsSection() { provider={provider} isConfigured={false} settings={getProviderSettings(provider)} - onSave={(providerSettings) => handleProviderSave(provider, providerSettings)} + onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)} disabled={!loginEnabled} /> ))} @@ -778,6 +596,15 @@ export default function AdminConnectionsSection() { onClose={closeRestartModal} onRestart={restartServer} /> - + + + +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx index 2bbe61972a..a654f5abd3 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { NumberInput, @@ -25,7 +25,9 @@ import { alert } from "@app/components/toast"; import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal"; import { useRestartServer } from "@app/components/shared/config/useRestartServer"; import { useAdminSettings } from "@app/hooks/useAdminSettings"; +import { useSettingsDirty } from "@app/hooks/useSettingsDirty"; import PendingBadge from "@app/components/shared/config/PendingBadge"; +import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; import EditableSecretField from "@app/components/shared/EditableSecretField"; @@ -66,7 +68,7 @@ export default function AdminDatabaseSection() { const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } = useAdminSettings({ sectionName: "database", - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { const response = await apiClient.get("/api/v1/admin/settings/section/system"); const systemData = response.data || {}; @@ -83,14 +85,14 @@ export default function AdminDatabaseSection() { }; // Map pending changes from system._pending.datasource to root level - const result: any = { ...datasource }; + const result: DatabaseSettingsData & { _pending?: Record } = { ...datasource }; if (systemData._pending?.datasource) { result._pending = systemData._pending.datasource; } return result; }, - saveTransformer: (settings) => { + saveTransformer: (settings: DatabaseSettingsData) => { // Convert flat settings to dot-notation for delta endpoint const deltaSettings: Record = { "system.datasource.enableCustomDatabase": settings.enableCustomDatabase, @@ -155,12 +157,15 @@ export default function AdminDatabaseSection() { loadBackupData(); }, [loginEnabled, isEmbeddedH2, isCustomDatabase, datasourceType]); + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + const handleSave = async () => { if (!validateLoginEnabled()) { return; } try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -172,6 +177,11 @@ export default function AdminDatabaseSection() { } }; + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + const handleCreateBackup = async () => { if (!validateLoginEnabled()) return; setCreatingBackup(true); @@ -340,8 +350,9 @@ export default function AdminDatabaseSection() { } return ( - - +
+ +
@@ -532,13 +543,17 @@ export default function AdminDatabaseSection() { - {/* Save Button */} - - - + + + + @@ -795,6 +810,7 @@ export default function AdminDatabaseSection() { - + +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx index 5a593a9897..61a760192e 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx @@ -1,11 +1,13 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Stack, Paper, Text, Loader, Group, MultiSelect, Switch } from '@mantine/core'; +import { Stack, Paper, Text, Loader, Group, MultiSelect, Switch } 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'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; @@ -55,13 +57,25 @@ export default function AdminEndpointsSection() { } }, [loginEnabled, fetchSettings, fetchUiSettings]); + const { isDirty: isEndpointsDirty, resetToSnapshot: resetEndpointsSnapshot, markSaved: markEndpointsSaved } = useSettingsDirty(settings, loading); + const { isDirty: isUiDirty, resetToSnapshot: resetUiSnapshot, markSaved: markUiSaved } = useSettingsDirty(uiSettings, uiLoading); + + const isDirty = isEndpointsDirty || isUiDirty; + const handleSave = async () => { if (!validateLoginEnabled()) { return; } try { - await saveSettings(); + if (isEndpointsDirty) { + markEndpointsSaved(); + await saveSettings(); + } + if (isUiDirty) { + markUiSaved(); + await saveUiSettings(); + } showRestartModal(); } catch (_error) { alert({ @@ -72,26 +86,16 @@ export default function AdminEndpointsSection() { } }; - const handleUiSave = async () => { - if (!validateLoginEnabled()) { - return; + const handleDiscard = useCallback(() => { + if (isEndpointsDirty) { + const original = resetEndpointsSnapshot(); + setSettings(original); } - - try { - await saveUiSettings(); - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saveSuccess', 'Settings saved successfully. Restart required for changes to take effect.'), - }); - } catch (_error) { - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.saveError', 'Failed to save settings'), - }); + if (isUiDirty) { + const original = resetUiSnapshot(); + setUiSettings(original); } - }; + }, [isEndpointsDirty, isUiDirty, resetEndpointsSnapshot, resetUiSnapshot, setSettings, setUiSettings]); // Override loading state when login is disabled const actualLoading = loginEnabled ? (loading || uiLoading) : false; @@ -214,8 +218,9 @@ export default function AdminEndpointsSection() { ]; return ( - - +
+ +
{t('admin.settings.endpoints.title', 'API Endpoints')} @@ -276,12 +281,6 @@ export default function AdminEndpointsSection() { - - - -
@@ -324,12 +323,15 @@ export default function AdminEndpointsSection() { /> + - - - + {/* Restart Confirmation Modal */} - +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx index 2153623bd5..ed9b6a1300 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx @@ -1,12 +1,14 @@ -import { useEffect } from 'react'; +import { useCallback, 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 { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, Badge } 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'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import apiClient from '@app/services/apiClient'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; @@ -36,11 +38,11 @@ export default function AdminFeaturesSection() { isFieldPending, } = useAdminSettings({ sectionName: 'features', - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system'); const systemData = systemResponse.data || {}; - const result: any = { + const result: FeaturesSettingsData & { _pending?: Record } = { serverCertificate: systemData.serverCertificate || { enabled: true, organizationName: 'Stirling-PDF', @@ -56,7 +58,7 @@ export default function AdminFeaturesSection() { return result; }, - saveTransformer: (settings) => { + saveTransformer: (settings: FeaturesSettingsData) => { const deltaSettings: Record = {}; if (settings.serverCertificate) { @@ -79,11 +81,14 @@ export default function AdminFeaturesSection() { } }, [loginEnabled]); + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + const handleSave = async () => { if (!validateLoginEnabled()) { return; } try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -95,6 +100,11 @@ export default function AdminFeaturesSection() { } }; + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + const actualLoading = loginEnabled ? loading : false; if (actualLoading) { @@ -106,8 +116,9 @@ export default function AdminFeaturesSection() { } return ( - - +
+ +
{t('admin.settings.features.title', 'Features')} @@ -223,13 +234,15 @@ export default function AdminFeaturesSection() {
+ - {/* Save Button */} - - - + {/* Restart Confirmation Modal */} - +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx index 3a50bd725c..a251aec67c 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx @@ -1,12 +1,14 @@ -import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; -import { TextInput, Textarea, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core'; +import { TextInput, Textarea, Switch, 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'; import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import apiClient from '@app/services/apiClient'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; @@ -56,7 +58,7 @@ export default function AdminGeneralSection() { const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { preferences, updatePreference } = usePreferences(); - const { setIsDirty, markClean } = useUnsavedChanges(); + const { markClean } = useUnsavedChanges(); const languageOptions = useMemo( () => Object.entries(supportedLanguages) .map(([code, label]) => ({ value: toUnderscoreFormat(code), label: `${label} (${code})` })) @@ -75,12 +77,6 @@ export default function AdminGeneralSection() { return uniquePaths; }, []); - // Track original settings for dirty detection - const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState(''); - const [isDirty, setLocalIsDirty] = useState(false); - const isInitialLoad = useRef(true); - const justSavedRef = useRef(false); - const { settings, setSettings, @@ -91,7 +87,7 @@ export default function AdminGeneralSection() { isFieldPending, } = useAdminSettings({ sectionName: 'general', - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { const [uiResponse, systemResponse, premiumResponse] = await Promise.all([ apiClient.get('/api/v1/admin/settings/section/ui'), apiClient.get('/api/v1/admin/settings/section/system'), @@ -113,7 +109,7 @@ export default function AdminGeneralSection() { ? watchedFoldersDirs : (pipelinePaths.watchedFoldersDir ? [pipelinePaths.watchedFoldersDir] : []); - const result: any = { + const result: GeneralSettingsData & { _pending?: Record } = { ui, system, customPaths: { @@ -140,7 +136,7 @@ export default function AdminGeneralSection() { }; // Merge pending blocks from all three endpoints - const pendingBlock: any = {}; + const pendingBlock: Record = {}; if (ui._pending) { pendingBlock.ui = ui._pending; } @@ -160,7 +156,7 @@ export default function AdminGeneralSection() { return result; }, - saveTransformer: (settings) => { + saveTransformer: (settings: GeneralSettingsData) => { const deltaSettings: Record = { // UI settings 'ui.appNameNavbar': settings.ui?.appNameNavbar, @@ -196,6 +192,8 @@ export default function AdminGeneralSection() { } }); + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + const selectedLanguages = useMemo( () => toUnderscoreLanguages(settings.ui?.languages || []), [settings.ui?.languages] @@ -266,49 +264,13 @@ export default function AdminGeneralSection() { } }, [loginEnabled, fetchSettings]); - // Snapshot original settings after initial load OR after successful save (when refetch completes) + // Sync local preference with server setting on initial load useEffect(() => { - if (loading || Object.keys(settings).length === 0) return; - - // After initial load: set snapshot and sync preference - if (isInitialLoad.current) { - setOriginalSettingsSnapshot(JSON.stringify(settings)); - - // Sync local preference with server setting on initial load to ensure they're in sync - // This ensures localStorage always reflects the server's authoritative value - if (loginEnabled && settings.ui?.logoStyle) { - updatePreference('logoVariant', settings.ui.logoStyle); - } - - isInitialLoad.current = false; - return; - } - - // After save: update snapshot to new server state so dirty tracking is accurate - if (justSavedRef.current) { - setOriginalSettingsSnapshot(JSON.stringify(settings)); - setLocalIsDirty(false); - setIsDirty(false); - justSavedRef.current = false; - } - }, [loading, settings, loginEnabled, updatePreference, setIsDirty]); + if (loading || !loginEnabled || !settings.ui?.logoStyle) return; - // Track dirty state by comparing current settings to snapshot - useEffect(() => { - if (!originalSettingsSnapshot || loading) return; - - const currentSnapshot = JSON.stringify(settings); - const dirty = currentSnapshot !== originalSettingsSnapshot; - setLocalIsDirty(dirty); - setIsDirty(dirty); - }, [settings, originalSettingsSnapshot, loading, setIsDirty]); - - // Clean up dirty state on unmount - useEffect(() => { - return () => { - setIsDirty(false); - }; - }, [setIsDirty]); + // This ensures localStorage always reflects the server's authoritative value + updatePreference('logoVariant', settings.ui.logoStyle); + }, [loading, loginEnabled, settings.ui?.logoStyle, updatePreference]); // Handle hash navigation for deep linking to specific fields useEffect(() => { @@ -324,17 +286,9 @@ export default function AdminGeneralSection() { }, [location.hash, loading]); const handleDiscard = useCallback(() => { - if (originalSettingsSnapshot) { - try { - const original = JSON.parse(originalSettingsSnapshot); - setSettings(original); - setLocalIsDirty(false); - setIsDirty(false); - } catch (e) { - console.error('Failed to parse original settings:', e); - } - } - }, [originalSettingsSnapshot, setSettings, setIsDirty]); + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); // Override loading state when login is disabled const actualLoading = loginEnabled ? loading : false; @@ -372,21 +326,18 @@ export default function AdminGeneralSection() { try { // Mark that we just saved - the snapshot will be updated when refetch completes - justSavedRef.current = true; - + markSaved(); + await saveSettings(); - + // Update local preference after successful save so the app reflects the saved logo style if (settings.ui?.logoStyle) { updatePreference('logoVariant', settings.ui.logoStyle); } - - // Clear dirty state immediately (snapshot will be updated by effect when refetch completes) - setLocalIsDirty(false); + markClean(); showRestartModal(); } catch (_error) { - justSavedRef.current = false; alert({ alertType: 'error', title: t('admin.error', 'Error'), @@ -877,24 +828,13 @@ export default function AdminGeneralSection() {
- {/* Sticky Save Footer - only shows when there are changes */} - {isDirty && loginEnabled && ( -
- - - {t('admin.settings.unsavedChanges.hint', 'You have unsaved changes')} - - - - - - -
- )} + {/* Restart Confirmation Modal */} { if (!validateLoginEnabled()) { return; } try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -57,6 +62,11 @@ export default function AdminLegalSection() { } }; + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + const actualLoading = loginEnabled ? loading : false; if (actualLoading) { @@ -68,7 +78,8 @@ export default function AdminLegalSection() { } return ( - +
+
{t('admin.settings.legal.title', 'Legal Documents')} @@ -175,12 +186,15 @@ export default function AdminLegalSection() {
+ - - - + {/* Restart Confirmation Modal */} - +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx index 7a1d99ed03..bf19cbc987 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx @@ -1,14 +1,17 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Anchor } from '@mantine/core'; +import { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, 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'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import EditableSecretField from '@app/components/shared/EditableSecretField'; import apiClient from '@app/services/apiClient'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; interface MailSettingsData { enabled?: boolean; @@ -30,6 +33,7 @@ type MailApiResponse = MailSettingsData & ApiResponseWithPending({ sectionName: 'mail', - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { const mailResponse = await apiClient.get('/api/v1/admin/settings/section/mail'); return mailResponse.data || {}; }, - saveTransformer: (settings) => { + saveTransformer: (settings: MailSettingsData) => { return { sectionData: settings, deltaSettings: {} @@ -58,8 +62,11 @@ export default function AdminMailSection() { fetchSettings(); }, []); + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + const handleSave = async () => { try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -71,6 +78,11 @@ export default function AdminMailSection() { } }; + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + if (loading) { return ( @@ -80,8 +92,9 @@ export default function AdminMailSection() { } return ( - -
+
+ +
{t('admin.settings.mail.title', 'Mail Configuration')} {t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')} @@ -203,12 +216,15 @@ export default function AdminMailSection() {
+ - - - + {/* Restart Confirmation Modal */} - +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx index 5c5de2f213..217b6d335b 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx @@ -1,12 +1,14 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert, List } from '@mantine/core'; +import { TextInput, Switch, Stack, Paper, Text, Loader, Group, Alert, List } 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'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; @@ -38,11 +40,14 @@ export default function AdminPremiumSection() { } }, [loginEnabled]); + + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); const handleSave = async () => { if (!validateLoginEnabled()) { return; } try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -54,6 +59,11 @@ export default function AdminPremiumSection() { } }; + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + const actualLoading = loginEnabled ? loading : false; if (actualLoading) { @@ -65,7 +75,8 @@ export default function AdminPremiumSection() { } return ( - +
+
{t('admin.settings.premium.title', 'Premium & Enterprise')} @@ -135,12 +146,15 @@ export default function AdminPremiumSection() {
+ - - - + {/* Restart Confirmation Modal */} - +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx index b3edede1c8..aea2593bfe 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx @@ -1,11 +1,13 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core'; +import { Switch, Stack, Paper, Text, Loader, Group } 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'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; import apiClient from '@app/services/apiClient'; @@ -31,7 +33,7 @@ export default function AdminPrivacySection() { isFieldPending, } = useAdminSettings({ sectionName: 'privacy', - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { const [metricsResponse, systemResponse] = await Promise.all([ apiClient.get('/api/v1/admin/settings/section/metrics'), apiClient.get('/api/v1/admin/settings/section/system') @@ -40,14 +42,14 @@ export default function AdminPrivacySection() { const metrics = metricsResponse.data; const system = systemResponse.data; - const result: any = { + const result: PrivacySettingsData & { _pending?: Record } = { enableAnalytics: system.enableAnalytics || false, googleVisibility: system.googlevisibility || false, metricsEnabled: metrics.enabled || false }; // Merge pending blocks from both endpoints - const pendingBlock: any = {}; + const pendingBlock: Record = {}; if (system._pending?.enableAnalytics !== undefined) { pendingBlock.enableAnalytics = system._pending.enableAnalytics; } @@ -64,7 +66,7 @@ export default function AdminPrivacySection() { return result; }, - saveTransformer: (settings) => { + saveTransformer: (settings: PrivacySettingsData) => { const deltaSettings = { 'system.enableAnalytics': settings.enableAnalytics, 'system.googlevisibility': settings.googleVisibility, @@ -84,12 +86,19 @@ export default function AdminPrivacySection() { } }, [loginEnabled, fetchSettings]); + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); const handleSave = async () => { if (!validateLoginEnabled()) { return; } try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -113,7 +122,8 @@ export default function AdminPrivacySection() { } return ( - +
+
@@ -200,12 +210,15 @@ export default function AdminPrivacySection() { - {/* Save Button */} - - - + + + {/* Restart Confirmation Modal */} - +
); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx index 2bfc8f1b44..612d74e5c9 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx @@ -1,12 +1,14 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, Alert, Badge, Accordion, Textarea } from '@mantine/core'; +import { NumberInput, Switch, Stack, Paper, Text, Loader, Group, Select, Alert, Badge, Accordion, Textarea } 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'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; import apiClient from '@app/services/apiClient'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; @@ -62,7 +64,7 @@ export default function AdminSecuritySection() { isFieldPending, } = useAdminSettings({ sectionName: 'security', - fetchTransformer: async () => { + fetchTransformer: async (): Promise }> => { const [securityResponse, premiumResponse, systemResponse] = await Promise.all([ apiClient.get('/api/v1/admin/settings/section/security'), apiClient.get('/api/v1/admin/settings/section/premium'), @@ -88,7 +90,7 @@ export default function AdminSecuritySection() { systemPending: JSON.parse(JSON.stringify(systemPending || {})) }); - const combined: any = { + const combined: SecuritySettingsData & { _pending?: Record } = { ...securityActive }; @@ -103,7 +105,7 @@ export default function AdminSecuritySection() { } // Merge all _pending blocks - const mergedPending: any = {}; + const mergedPending: Record = {}; if (securityPending) { Object.assign(mergedPending, securityPending); } @@ -120,7 +122,7 @@ export default function AdminSecuritySection() { return combined; }, - saveTransformer: (settings) => { + saveTransformer: (settings: SecuritySettingsData) => { const { audit, html, ...securitySettings } = settings; const deltaSettings: Record = { @@ -165,6 +167,8 @@ export default function AdminSecuritySection() { } }); + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + useEffect(() => { if (loginEnabled) { fetchSettings(); @@ -181,6 +185,7 @@ export default function AdminSecuritySection() { } try { + markSaved(); await saveSettings(); showRestartModal(); } catch (_error) { @@ -192,6 +197,11 @@ export default function AdminSecuritySection() { } }; + const handleDiscard = useCallback(() => { + const original = resetToSnapshot(); + setSettings(original); + }, [resetToSnapshot, setSettings]); + if (actualLoading) { return ( @@ -201,8 +211,9 @@ export default function AdminSecuritySection() { } return ( - - +
+ +
{t('admin.settings.security.title', 'Security')} @@ -803,13 +814,15 @@ export default function AdminSecuritySection() { + - {/* Save Button */} - - - + {/* Restart Confirmation Modal */} - +
); }