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 */}
-
+
);
}