mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
settings menu reworks (#5864)
This commit is contained in:
@@ -873,6 +873,36 @@ public class GeneralUtils {
|
|||||||
settingsYaml.saveOverride(settingsPath);
|
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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Object> 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<String, Object> 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.
|
* Machine fingerprint generation with better error logging and fallbacks.
|
||||||
*
|
*
|
||||||
|
|||||||
+11
-8
@@ -172,7 +172,7 @@ public class AdminSettingsController {
|
|||||||
.body(Map.of("error", "No settings provided to update"));
|
.body(Map.of("error", "No settings provided to update"));
|
||||||
}
|
}
|
||||||
|
|
||||||
int updatedCount = 0;
|
// Validate all settings first before applying any changes
|
||||||
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||||
String key = entry.getKey();
|
String key = entry.getKey();
|
||||||
Object value = entry.getValue();
|
Object value = entry.getValue();
|
||||||
@@ -192,15 +192,18 @@ public class AdminSettingsController {
|
|||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body(Map.of("error", HtmlUtils.htmlEscape(validationError)));
|
.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<String, Object> entry : settings.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
Object value = entry.getValue();
|
||||||
log.info("Admin updating setting: {} = {}", key, value);
|
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 : "");
|
pendingChanges.put(key, value != null ? value : "");
|
||||||
|
|
||||||
updatedCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
@@ -209,7 +212,7 @@ public class AdminSettingsController {
|
|||||||
String.format(
|
String.format(
|
||||||
"Successfully updated %d setting(s). Changes will take effect on"
|
"Successfully updated %d setting(s). Changes will take effect on"
|
||||||
+ " application restart.",
|
+ " application restart.",
|
||||||
updatedCount)));
|
settings.size())));
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to save settings to file: {}", e.getMessage(), e);
|
log.error("Failed to save settings to file: {}", e.getMessage(), e);
|
||||||
|
|||||||
+3
-4
@@ -28,7 +28,7 @@ ext {
|
|||||||
springSecuritySamlVersion = "7.0.2"
|
springSecuritySamlVersion = "7.0.2"
|
||||||
openSamlVersion = "5.2.1"
|
openSamlVersion = "5.2.1"
|
||||||
commonmarkVersion = "0.27.1"
|
commonmarkVersion = "0.27.1"
|
||||||
googleJavaFormatVersion = "1.34.1"
|
googleJavaFormatVersion = "1.21.0"
|
||||||
logback = "1.5.32"
|
logback = "1.5.32"
|
||||||
junitPlatformVersion = "1.12.2"
|
junitPlatformVersion = "1.12.2"
|
||||||
modernJavaVersion = 21
|
modernJavaVersion = 21
|
||||||
@@ -177,9 +177,8 @@ subprojects {
|
|||||||
exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on'
|
exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on'
|
||||||
exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on'
|
exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on'
|
||||||
exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on'
|
exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on'
|
||||||
// google-java-format 1.34+ requires Guava 33.x (ImmutableSortedMapFauxverideShim);
|
// Force a compatible Guava version for spotless
|
||||||
// force it here so Spotless's FeatureClassLoader resolves the correct version.
|
resolutionStrategy.force 'com.google.guava:guava:33.0.0-jre'
|
||||||
resolutionStrategy.force 'com.google.guava:guava:33.4.8-jre'
|
|
||||||
|
|
||||||
// Security CVE fixes - hardcoded resolution strategy to ensure safe versions
|
// Security CVE fixes - hardcoded resolution strategy to ensure safe versions
|
||||||
// Primary fixes via explicit dependencies in app/core/build.gradle:
|
// Primary fixes via explicit dependencies in app/core/build.gradle:
|
||||||
|
|||||||
@@ -652,6 +652,7 @@ description = "Configure external authentication providers like OAuth2 and SAML.
|
|||||||
disconnect = "Disconnect"
|
disconnect = "Disconnect"
|
||||||
disconnected = "Provider disconnected successfully"
|
disconnected = "Provider disconnected successfully"
|
||||||
disconnectError = "Failed to disconnect provider"
|
disconnectError = "Failed to disconnect provider"
|
||||||
|
documentation = "View documentation"
|
||||||
imageResolutionFull = "Full (Original Size)"
|
imageResolutionFull = "Full (Original Size)"
|
||||||
imageResolutionReduced = "Reduced (Max 1200px)"
|
imageResolutionReduced = "Reduced (Max 1200px)"
|
||||||
linkedServices = "Linked Services"
|
linkedServices = "Linked Services"
|
||||||
@@ -965,6 +966,18 @@ label = "Default Locale"
|
|||||||
description = "Maximum file upload size (e.g., 100MB, 1GB)"
|
description = "Maximum file upload size (e.g., 100MB, 1GB)"
|
||||||
label = "File Upload Limit"
|
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]
|
[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."
|
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"
|
label = "Frontend URL"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="settings-sticky-footer">
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('admin.settings.unsavedChanges.hint', 'You have unsaved changes')}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button variant="default" onClick={onDiscard} size="sm">
|
||||||
|
{t('admin.settings.discard', 'Discard')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSave} loading={saving} size="sm">
|
||||||
|
{t('admin.settings.save', 'Save Changes')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,9 @@ interface ProviderCardProps {
|
|||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
onSave?: (settings: Record<string, any>) => void;
|
onSave?: (settings: Record<string, any>) => void;
|
||||||
onDisconnect?: () => void;
|
onDisconnect?: () => void;
|
||||||
|
onChange?: (settings: Record<string, any>) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProviderCard({
|
export default function ProviderCard({
|
||||||
@@ -32,18 +34,19 @@ export default function ProviderCard({
|
|||||||
settings = {},
|
settings = {},
|
||||||
onSave,
|
onSave,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
|
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
|
||||||
|
|
||||||
// Keep local settings in sync with incoming settings (values loaded from settings.yml)
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!expanded) {
|
setLocalSettings(settings);
|
||||||
setLocalSettings(settings);
|
}, [settings]);
|
||||||
}
|
|
||||||
}, [settings, expanded]);
|
|
||||||
|
|
||||||
// Initialize local settings with defaults when opening an unconfigured provider
|
// Initialize local settings with defaults when opening an unconfigured provider
|
||||||
const handleConnectToggle = () => {
|
const handleConnectToggle = () => {
|
||||||
@@ -63,7 +66,12 @@ export default function ProviderCard({
|
|||||||
|
|
||||||
const handleFieldChange = (key: string, value: any) => {
|
const handleFieldChange = (key: string, value: any) => {
|
||||||
if (disabled) return; // Block changes when disabled
|
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 = () => {
|
const handleSave = () => {
|
||||||
@@ -225,22 +233,26 @@ export default function ProviderCard({
|
|||||||
<Stack gap="md" mt="xs">
|
<Stack gap="md" mt="xs">
|
||||||
{provider.fields.map((field) => renderField(field))}
|
{provider.fields.map((field) => renderField(field))}
|
||||||
|
|
||||||
<Group justify="flex-end" mt="sm">
|
{!readOnly && (onSave || onDisconnect) && (
|
||||||
{onDisconnect && (
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button
|
{onDisconnect && (
|
||||||
variant="outline"
|
<Button
|
||||||
color="red"
|
variant="outline"
|
||||||
size="sm"
|
color="red"
|
||||||
onClick={onDisconnect}
|
size="sm"
|
||||||
disabled={disabled}
|
onClick={onDisconnect}
|
||||||
>
|
disabled={disabled}
|
||||||
{t('admin.settings.connections.disconnect', 'Disconnect')}
|
>
|
||||||
</Button>
|
{t('admin.settings.connections.disconnect', 'Disconnect')}
|
||||||
)}
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
)}
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
{onSave && (
|
||||||
</Button>
|
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||||
</Group>
|
{t('admin.settings.save', 'Save Changes')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
|
||||||
|
|
||||||
|
interface UseSettingsDirtyReturn<T> {
|
||||||
|
isDirty: boolean;
|
||||||
|
resetToSnapshot: () => T;
|
||||||
|
markSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing dirty state in settings sections
|
||||||
|
* Handles JSON snapshot comparison and UnsavedChangesContext integration
|
||||||
|
*/
|
||||||
|
export function useSettingsDirty<T>(settings: T, loading: boolean): UseSettingsDirtyReturn<T> {
|
||||||
|
const { setIsDirty } = useUnsavedChanges();
|
||||||
|
const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState<string>('');
|
||||||
|
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<string, unknown>).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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+27
-13
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput, MultiSelect } from '@mantine/core';
|
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput, MultiSelect } from '@mantine/core';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
@@ -70,7 +72,7 @@ export default function AdminAdvancedSection() {
|
|||||||
isFieldPending,
|
isFieldPending,
|
||||||
} = useAdminSettings<AdvancedSettingsData>({
|
} = useAdminSettings<AdvancedSettingsData>({
|
||||||
sectionName: 'advanced',
|
sectionName: 'advanced',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<AdvancedSettingsData & { _pending?: Record<string, any> }> => {
|
||||||
const [systemResponse, processExecutorResponse] = await Promise.all([
|
const [systemResponse, processExecutorResponse] = await Promise.all([
|
||||||
apiClient.get('/api/v1/admin/settings/section/system'),
|
apiClient.get('/api/v1/admin/settings/section/system'),
|
||||||
apiClient.get('/api/v1/admin/settings/section/processExecutor')
|
apiClient.get('/api/v1/admin/settings/section/processExecutor')
|
||||||
@@ -79,7 +81,7 @@ export default function AdminAdvancedSection() {
|
|||||||
const systemData = systemResponse.data || {};
|
const systemData = systemResponse.data || {};
|
||||||
const processExecutorData = processExecutorResponse.data || {};
|
const processExecutorData = processExecutorResponse.data || {};
|
||||||
|
|
||||||
const result: any = {
|
const result: AdvancedSettingsData & { _pending?: Record<string, any> } = {
|
||||||
enableAlphaFunctionality: systemData.enableAlphaFunctionality || false,
|
enableAlphaFunctionality: systemData.enableAlphaFunctionality || false,
|
||||||
maxDPI: systemData.maxDPI || 0,
|
maxDPI: systemData.maxDPI || 0,
|
||||||
enableUrlToPDF: systemData.enableUrlToPDF || false,
|
enableUrlToPDF: systemData.enableUrlToPDF || false,
|
||||||
@@ -99,7 +101,7 @@ export default function AdminAdvancedSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Merge pending blocks from both endpoints
|
// Merge pending blocks from both endpoints
|
||||||
const pendingBlock: any = {};
|
const pendingBlock: Record<string, any> = {};
|
||||||
if (systemData._pending?.enableAlphaFunctionality !== undefined) {
|
if (systemData._pending?.enableAlphaFunctionality !== undefined) {
|
||||||
pendingBlock.enableAlphaFunctionality = systemData._pending.enableAlphaFunctionality;
|
pendingBlock.enableAlphaFunctionality = systemData._pending.enableAlphaFunctionality;
|
||||||
}
|
}
|
||||||
@@ -334,11 +336,14 @@ export default function AdminAdvancedSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} 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;
|
const actualLoading = loginEnabled ? loading : false;
|
||||||
|
|
||||||
if (actualLoading) {
|
if (actualLoading) {
|
||||||
@@ -361,8 +371,9 @@ export default function AdminAdvancedSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
|
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -1092,12 +1103,15 @@ export default function AdminAdvancedSection() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Save Button */}
|
</Stack>
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
<SettingsStickyFooter
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
isDirty={isDirty}
|
||||||
</Button>
|
saving={saving}
|
||||||
</Group>
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -1105,6 +1119,6 @@ export default function AdminAdvancedSection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-323
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor, Select, Collapse } from '@mantine/core';
|
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 RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
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 { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
|
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
|
||||||
import { Provider, useAllProviders } from '@app/components/shared/config/configSections/providerDefinitions';
|
import { Provider, useAllProviders } from '@app/components/shared/config/configSections/providerDefinitions';
|
||||||
@@ -81,13 +83,13 @@ interface ConnectionsSettingsData {
|
|||||||
export default function AdminConnectionsSection() {
|
export default function AdminConnectionsSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
const { loginEnabled, getDisabledStyles } = useLoginRequired();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
const { restartModalOpened, closeRestartModal, restartServer } = useRestartServer();
|
||||||
const allProviders = useAllProviders();
|
const allProviders = useAllProviders();
|
||||||
|
|
||||||
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
|
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
|
||||||
sectionName: 'connections',
|
sectionName: 'connections',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<ConnectionsSettingsData & { _pending?: Record<string, any> }> => {
|
||||||
// Fetch security settings (oauth2, saml2)
|
// Fetch security settings (oauth2, saml2)
|
||||||
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
|
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
|
||||||
const securityData = securityResponse.data || {};
|
const securityData = securityResponse.data || {};
|
||||||
@@ -108,7 +110,7 @@ export default function AdminConnectionsSection() {
|
|||||||
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||||
const systemData = systemResponse.data || {};
|
const systemData = systemResponse.data || {};
|
||||||
|
|
||||||
const result: any = {
|
const result: ConnectionsSettingsData & { _pending?: Record<string, any> } = {
|
||||||
oauth2: securityData.oauth2 || {},
|
oauth2: securityData.oauth2 || {},
|
||||||
saml2: securityData.saml2 || {},
|
saml2: securityData.saml2 || {},
|
||||||
mail: mailData || {},
|
mail: mailData || {},
|
||||||
@@ -121,51 +123,93 @@ export default function AdminConnectionsSection() {
|
|||||||
mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false
|
mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge pending blocks from all four endpoints
|
// Merge pending blocks from all endpoints - initialize with defaults to avoid warnings
|
||||||
const pendingBlock: any = {};
|
const pendingBlock: Record<string, any> = {
|
||||||
if (securityData._pending?.oauth2) {
|
oauth2: securityData._pending?.oauth2,
|
||||||
pendingBlock.oauth2 = securityData._pending.oauth2;
|
saml2: securityData._pending?.saml2,
|
||||||
}
|
mail: mailData._pending,
|
||||||
if (securityData._pending?.saml2) {
|
telegram: telegramData._pending,
|
||||||
pendingBlock.saml2 = securityData._pending.saml2;
|
ssoAutoLogin: premiumData._pending?.proFeatures?.ssoAutoLogin,
|
||||||
}
|
enableMobileScanner: systemData._pending?.enableMobileScanner,
|
||||||
if (mailData._pending) {
|
mobileScannerConvertToPdf: systemData._pending?.mobileScannerSettings?.convertToPdf,
|
||||||
pendingBlock.mail = mailData._pending;
|
mobileScannerImageResolution: systemData._pending?.mobileScannerSettings?.imageResolution,
|
||||||
}
|
mobileScannerPageFormat: systemData._pending?.mobileScannerSettings?.pageFormat,
|
||||||
if (telegramData._pending) {
|
mobileScannerStretchToFit: systemData._pending?.mobileScannerSettings?.stretchToFit,
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(pendingBlock).length > 0) {
|
result._pending = pendingBlock;
|
||||||
result._pending = pendingBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
saveTransformer: () => {
|
saveTransformer: (currentSettings: ConnectionsSettingsData) => {
|
||||||
// This section doesn't have a global save button
|
const deltaSettings: Record<string, any> = {};
|
||||||
// Individual providers save through their own handlers
|
|
||||||
|
// 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<string, any>)[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<string, any>)[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mail settings
|
||||||
|
if (currentSettings.mail) {
|
||||||
|
Object.keys(currentSettings.mail).forEach((key) => {
|
||||||
|
deltaSettings[`mail.${key}`] = (currentSettings.mail as Record<string, any>)[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram settings
|
||||||
|
if (currentSettings.telegram) {
|
||||||
|
Object.keys(currentSettings.telegram).forEach((key) => {
|
||||||
|
deltaSettings[`telegram.${key}`] = (currentSettings.telegram as Record<string, any>)[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 {
|
return {
|
||||||
sectionData: {},
|
sectionData: {},
|
||||||
deltaSettings: {}
|
deltaSettings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -184,6 +228,26 @@ export default function AdminConnectionsSection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled, fetchSettings]);
|
}, [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
|
// Override loading state when login is disabled
|
||||||
const actualLoading = loginEnabled ? loading : false;
|
const actualLoading = loginEnabled ? loading : false;
|
||||||
|
|
||||||
@@ -241,175 +305,6 @@ export default function AdminConnectionsSection() {
|
|||||||
return settings?.oauth2?.client?.[provider.id] || {};
|
return settings?.oauth2?.client?.[provider.id] || {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
|
|
||||||
// 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<string, any> = {};
|
|
||||||
|
|
||||||
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<string, any> = {};
|
|
||||||
|
|
||||||
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) {
|
if (actualLoading) {
|
||||||
return (
|
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 linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
|
||||||
const availableProviders = allProviders.filter((p) => !isProviderConfigured(p));
|
const availableProviders = allProviders.filter((p) => !isProviderConfigured(p));
|
||||||
|
|
||||||
|
const updateProviderSettings = (provider: Provider, updatedSettings: Record<string, any>) => {
|
||||||
|
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 (
|
return (
|
||||||
<Stack gap="xl">
|
<div className="settings-section-container">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<Stack gap="xl" className="settings-section-content">
|
||||||
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
@@ -561,7 +389,6 @@ export default function AdminConnectionsSection() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (!loginEnabled) return; // Block change when login disabled
|
if (!loginEnabled) return; // Block change when login disabled
|
||||||
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
||||||
handleSSOAutoLoginSave();
|
|
||||||
}}
|
}}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
styles={getDisabledStyles()}
|
styles={getDisabledStyles()}
|
||||||
@@ -598,9 +425,7 @@ export default function AdminConnectionsSection() {
|
|||||||
checked={settings?.enableMobileScanner || false}
|
checked={settings?.enableMobileScanner || false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (!loginEnabled) return; // Block change when login disabled
|
if (!loginEnabled) return; // Block change when login disabled
|
||||||
const newValue = e.target.checked;
|
setSettings({ ...settings, enableMobileScanner: e.target.checked });
|
||||||
setSettings({ ...settings, enableMobileScanner: newValue });
|
|
||||||
handleMobileScannerSave(newValue);
|
|
||||||
}}
|
}}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
styles={getDisabledStyles()}
|
styles={getDisabledStyles()}
|
||||||
@@ -625,9 +450,7 @@ export default function AdminConnectionsSection() {
|
|||||||
checked={settings?.mobileScannerConvertToPdf !== false}
|
checked={settings?.mobileScannerConvertToPdf !== false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (!loginEnabled) return;
|
if (!loginEnabled) return;
|
||||||
const newValue = e.target.checked;
|
setSettings({ ...settings, mobileScannerConvertToPdf: e.target.checked });
|
||||||
setSettings({ ...settings, mobileScannerConvertToPdf: newValue });
|
|
||||||
handleMobileScannerSettingsSave('convertToPdf', newValue);
|
|
||||||
}}
|
}}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -652,7 +475,6 @@ export default function AdminConnectionsSection() {
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!loginEnabled) return;
|
if (!loginEnabled) return;
|
||||||
setSettings({ ...settings, mobileScannerImageResolution: value || 'full' });
|
setSettings({ ...settings, mobileScannerImageResolution: value || 'full' });
|
||||||
handleMobileScannerSettingsSave('imageResolution', value || 'full');
|
|
||||||
}}
|
}}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'full', label: t('admin.settings.connections.imageResolutionFull', 'Full (Original Size)') },
|
{ value: 'full', label: t('admin.settings.connections.imageResolutionFull', 'Full (Original Size)') },
|
||||||
@@ -680,7 +502,6 @@ export default function AdminConnectionsSection() {
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!loginEnabled) return;
|
if (!loginEnabled) return;
|
||||||
setSettings({ ...settings, mobileScannerPageFormat: value || 'A4' });
|
setSettings({ ...settings, mobileScannerPageFormat: value || 'A4' });
|
||||||
handleMobileScannerSettingsSave('pageFormat', value || 'A4');
|
|
||||||
}}
|
}}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'keep', label: t('admin.settings.connections.pageFormatKeep', 'Keep (Original Dimensions)') },
|
{ value: 'keep', label: t('admin.settings.connections.pageFormatKeep', 'Keep (Original Dimensions)') },
|
||||||
@@ -708,9 +529,7 @@ export default function AdminConnectionsSection() {
|
|||||||
checked={settings?.mobileScannerStretchToFit || false}
|
checked={settings?.mobileScannerStretchToFit || false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (!loginEnabled) return;
|
if (!loginEnabled) return;
|
||||||
const newValue = e.target.checked;
|
setSettings({ ...settings, mobileScannerStretchToFit: e.target.checked });
|
||||||
setSettings({ ...settings, mobileScannerStretchToFit: newValue });
|
|
||||||
handleMobileScannerSettingsSave('stretchToFit', newValue);
|
|
||||||
}}
|
}}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -738,8 +557,7 @@ export default function AdminConnectionsSection() {
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
isConfigured={true}
|
isConfigured={true}
|
||||||
settings={getProviderSettings(provider)}
|
settings={getProviderSettings(provider)}
|
||||||
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
|
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
|
||||||
onDisconnect={() => handleProviderDisconnect(provider)}
|
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -764,7 +582,7 @@ export default function AdminConnectionsSection() {
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
isConfigured={false}
|
isConfigured={false}
|
||||||
settings={getProviderSettings(provider)}
|
settings={getProviderSettings(provider)}
|
||||||
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
|
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -778,6 +596,15 @@ export default function AdminConnectionsSection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<SettingsStickyFooter
|
||||||
|
isDirty={isDirty}
|
||||||
|
saving={adminSettings.saving}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-13
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
NumberInput,
|
NumberInput,
|
||||||
@@ -25,7 +25,9 @@ import { alert } from "@app/components/toast";
|
|||||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||||
|
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||||
|
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||||
import EditableSecretField from "@app/components/shared/EditableSecretField";
|
import EditableSecretField from "@app/components/shared/EditableSecretField";
|
||||||
@@ -66,7 +68,7 @@ export default function AdminDatabaseSection() {
|
|||||||
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
|
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
|
||||||
useAdminSettings<DatabaseSettingsData>({
|
useAdminSettings<DatabaseSettingsData>({
|
||||||
sectionName: "database",
|
sectionName: "database",
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<DatabaseSettingsData & { _pending?: Record<string, any> }> => {
|
||||||
const response = await apiClient.get("/api/v1/admin/settings/section/system");
|
const response = await apiClient.get("/api/v1/admin/settings/section/system");
|
||||||
const systemData = response.data || {};
|
const systemData = response.data || {};
|
||||||
|
|
||||||
@@ -83,14 +85,14 @@ export default function AdminDatabaseSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Map pending changes from system._pending.datasource to root level
|
// Map pending changes from system._pending.datasource to root level
|
||||||
const result: any = { ...datasource };
|
const result: DatabaseSettingsData & { _pending?: Record<string, any> } = { ...datasource };
|
||||||
if (systemData._pending?.datasource) {
|
if (systemData._pending?.datasource) {
|
||||||
result._pending = systemData._pending.datasource;
|
result._pending = systemData._pending.datasource;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings: DatabaseSettingsData) => {
|
||||||
// Convert flat settings to dot-notation for delta endpoint
|
// Convert flat settings to dot-notation for delta endpoint
|
||||||
const deltaSettings: Record<string, any> = {
|
const deltaSettings: Record<string, any> = {
|
||||||
"system.datasource.enableCustomDatabase": settings.enableCustomDatabase,
|
"system.datasource.enableCustomDatabase": settings.enableCustomDatabase,
|
||||||
@@ -155,12 +157,15 @@ export default function AdminDatabaseSection() {
|
|||||||
loadBackupData();
|
loadBackupData();
|
||||||
}, [loginEnabled, isEmbeddedH2, isCustomDatabase, datasourceType]);
|
}, [loginEnabled, isEmbeddedH2, isCustomDatabase, datasourceType]);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -172,6 +177,11 @@ export default function AdminDatabaseSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
const original = resetToSnapshot();
|
||||||
|
setSettings(original);
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
const handleCreateBackup = async () => {
|
||||||
if (!validateLoginEnabled()) return;
|
if (!validateLoginEnabled()) return;
|
||||||
setCreatingBackup(true);
|
setCreatingBackup(true);
|
||||||
@@ -340,8 +350,9 @@ export default function AdminDatabaseSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
@@ -532,13 +543,17 @@ export default function AdminDatabaseSection() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Save Button */}
|
</Stack>
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
|
||||||
{t("admin.settings.save", "Save Changes")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
|
<SettingsStickyFooter
|
||||||
|
isDirty={isDirty}
|
||||||
|
saving={saving}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="lg" className="settings-section-content" style={{ marginTop: 0 }}>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -795,6 +810,7 @@ export default function AdminDatabaseSection() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-35
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
|
|
||||||
@@ -55,13 +57,25 @@ export default function AdminEndpointsSection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled, fetchSettings, fetchUiSettings]);
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveSettings();
|
if (isEndpointsDirty) {
|
||||||
|
markEndpointsSaved();
|
||||||
|
await saveSettings();
|
||||||
|
}
|
||||||
|
if (isUiDirty) {
|
||||||
|
markUiSaved();
|
||||||
|
await saveUiSettings();
|
||||||
|
}
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
alert({
|
alert({
|
||||||
@@ -72,26 +86,16 @@ export default function AdminEndpointsSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUiSave = async () => {
|
const handleDiscard = useCallback(() => {
|
||||||
if (!validateLoginEnabled()) {
|
if (isEndpointsDirty) {
|
||||||
return;
|
const original = resetEndpointsSnapshot();
|
||||||
|
setSettings(original);
|
||||||
}
|
}
|
||||||
|
if (isUiDirty) {
|
||||||
try {
|
const original = resetUiSnapshot();
|
||||||
await saveUiSettings();
|
setUiSettings(original);
|
||||||
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'),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}, [isEndpointsDirty, isUiDirty, resetEndpointsSnapshot, resetUiSnapshot, setSettings, setUiSettings]);
|
||||||
|
|
||||||
// Override loading state when login is disabled
|
// Override loading state when login is disabled
|
||||||
const actualLoading = loginEnabled ? (loading || uiLoading) : false;
|
const actualLoading = loginEnabled ? (loading || uiLoading) : false;
|
||||||
@@ -214,8 +218,9 @@ export default function AdminEndpointsSection() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
|
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
|
||||||
@@ -276,12 +281,6 @@ export default function AdminEndpointsSection() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
|
||||||
{t('admin.settings.save', 'Save Endpoint Settings')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div>
|
<div>
|
||||||
@@ -324,12 +323,15 @@ export default function AdminEndpointsSection() {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<SettingsStickyFooter
|
||||||
<Button onClick={handleUiSave} loading={uiSaving} size="sm" disabled={!loginEnabled}>
|
isDirty={isDirty}
|
||||||
{t('admin.settings.save', 'Save User Defaults')}
|
saving={saving || uiSaving}
|
||||||
</Button>
|
loginEnabled={loginEnabled}
|
||||||
</Group>
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -337,6 +339,6 @@ export default function AdminEndpointsSection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-14
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
@@ -36,11 +38,11 @@ export default function AdminFeaturesSection() {
|
|||||||
isFieldPending,
|
isFieldPending,
|
||||||
} = useAdminSettings<FeaturesSettingsData>({
|
} = useAdminSettings<FeaturesSettingsData>({
|
||||||
sectionName: 'features',
|
sectionName: 'features',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<FeaturesSettingsData & { _pending?: Record<string, any> }> => {
|
||||||
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||||
const systemData = systemResponse.data || {};
|
const systemData = systemResponse.data || {};
|
||||||
|
|
||||||
const result: any = {
|
const result: FeaturesSettingsData & { _pending?: Record<string, any> } = {
|
||||||
serverCertificate: systemData.serverCertificate || {
|
serverCertificate: systemData.serverCertificate || {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
organizationName: 'Stirling-PDF',
|
organizationName: 'Stirling-PDF',
|
||||||
@@ -56,7 +58,7 @@ export default function AdminFeaturesSection() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings: FeaturesSettingsData) => {
|
||||||
const deltaSettings: Record<string, any> = {};
|
const deltaSettings: Record<string, any> = {};
|
||||||
|
|
||||||
if (settings.serverCertificate) {
|
if (settings.serverCertificate) {
|
||||||
@@ -79,11 +81,14 @@ export default function AdminFeaturesSection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled]);
|
}, [loginEnabled]);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} 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;
|
const actualLoading = loginEnabled ? loading : false;
|
||||||
|
|
||||||
if (actualLoading) {
|
if (actualLoading) {
|
||||||
@@ -106,8 +116,9 @@ export default function AdminFeaturesSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
|
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -223,13 +234,15 @@ export default function AdminFeaturesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{/* Save Button */}
|
<SettingsStickyFooter
|
||||||
<Group justify="flex-end">
|
isDirty={isDirty}
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
saving={saving}
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
loginEnabled={loginEnabled}
|
||||||
</Button>
|
onSave={handleSave}
|
||||||
</Group>
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -237,6 +250,6 @@ export default function AdminFeaturesSection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-90
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import { useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
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 { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
@@ -56,7 +58,7 @@ export default function AdminGeneralSection() {
|
|||||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||||
const { preferences, updatePreference } = usePreferences();
|
const { preferences, updatePreference } = usePreferences();
|
||||||
const { setIsDirty, markClean } = useUnsavedChanges();
|
const { markClean } = useUnsavedChanges();
|
||||||
const languageOptions = useMemo(
|
const languageOptions = useMemo(
|
||||||
() => Object.entries(supportedLanguages)
|
() => Object.entries(supportedLanguages)
|
||||||
.map(([code, label]) => ({ value: toUnderscoreFormat(code), label: `${label} (${code})` }))
|
.map(([code, label]) => ({ value: toUnderscoreFormat(code), label: `${label} (${code})` }))
|
||||||
@@ -75,12 +77,6 @@ export default function AdminGeneralSection() {
|
|||||||
return uniquePaths;
|
return uniquePaths;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Track original settings for dirty detection
|
|
||||||
const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState<string>('');
|
|
||||||
const [isDirty, setLocalIsDirty] = useState(false);
|
|
||||||
const isInitialLoad = useRef(true);
|
|
||||||
const justSavedRef = useRef(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
settings,
|
settings,
|
||||||
setSettings,
|
setSettings,
|
||||||
@@ -91,7 +87,7 @@ export default function AdminGeneralSection() {
|
|||||||
isFieldPending,
|
isFieldPending,
|
||||||
} = useAdminSettings<GeneralSettingsData>({
|
} = useAdminSettings<GeneralSettingsData>({
|
||||||
sectionName: 'general',
|
sectionName: 'general',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<GeneralSettingsData & { _pending?: Record<string, any> }> => {
|
||||||
const [uiResponse, systemResponse, premiumResponse] = await Promise.all([
|
const [uiResponse, systemResponse, premiumResponse] = await Promise.all([
|
||||||
apiClient.get('/api/v1/admin/settings/section/ui'),
|
apiClient.get('/api/v1/admin/settings/section/ui'),
|
||||||
apiClient.get('/api/v1/admin/settings/section/system'),
|
apiClient.get('/api/v1/admin/settings/section/system'),
|
||||||
@@ -113,7 +109,7 @@ export default function AdminGeneralSection() {
|
|||||||
? watchedFoldersDirs
|
? watchedFoldersDirs
|
||||||
: (pipelinePaths.watchedFoldersDir ? [pipelinePaths.watchedFoldersDir] : []);
|
: (pipelinePaths.watchedFoldersDir ? [pipelinePaths.watchedFoldersDir] : []);
|
||||||
|
|
||||||
const result: any = {
|
const result: GeneralSettingsData & { _pending?: Record<string, any> } = {
|
||||||
ui,
|
ui,
|
||||||
system,
|
system,
|
||||||
customPaths: {
|
customPaths: {
|
||||||
@@ -140,7 +136,7 @@ export default function AdminGeneralSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Merge pending blocks from all three endpoints
|
// Merge pending blocks from all three endpoints
|
||||||
const pendingBlock: any = {};
|
const pendingBlock: Record<string, any> = {};
|
||||||
if (ui._pending) {
|
if (ui._pending) {
|
||||||
pendingBlock.ui = ui._pending;
|
pendingBlock.ui = ui._pending;
|
||||||
}
|
}
|
||||||
@@ -160,7 +156,7 @@ export default function AdminGeneralSection() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings: GeneralSettingsData) => {
|
||||||
const deltaSettings: Record<string, any> = {
|
const deltaSettings: Record<string, any> = {
|
||||||
// UI settings
|
// UI settings
|
||||||
'ui.appNameNavbar': settings.ui?.appNameNavbar,
|
'ui.appNameNavbar': settings.ui?.appNameNavbar,
|
||||||
@@ -196,6 +192,8 @@ export default function AdminGeneralSection() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
|
|
||||||
const selectedLanguages = useMemo(
|
const selectedLanguages = useMemo(
|
||||||
() => toUnderscoreLanguages(settings.ui?.languages || []),
|
() => toUnderscoreLanguages(settings.ui?.languages || []),
|
||||||
[settings.ui?.languages]
|
[settings.ui?.languages]
|
||||||
@@ -266,49 +264,13 @@ export default function AdminGeneralSection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled, fetchSettings]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (loading || Object.keys(settings).length === 0) return;
|
if (loading || !loginEnabled || !settings.ui?.logoStyle) 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]);
|
|
||||||
|
|
||||||
// Track dirty state by comparing current settings to snapshot
|
// This ensures localStorage always reflects the server's authoritative value
|
||||||
useEffect(() => {
|
updatePreference('logoVariant', settings.ui.logoStyle);
|
||||||
if (!originalSettingsSnapshot || loading) return;
|
}, [loading, loginEnabled, settings.ui?.logoStyle, updatePreference]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Handle hash navigation for deep linking to specific fields
|
// Handle hash navigation for deep linking to specific fields
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -324,17 +286,9 @@ export default function AdminGeneralSection() {
|
|||||||
}, [location.hash, loading]);
|
}, [location.hash, loading]);
|
||||||
|
|
||||||
const handleDiscard = useCallback(() => {
|
const handleDiscard = useCallback(() => {
|
||||||
if (originalSettingsSnapshot) {
|
const original = resetToSnapshot();
|
||||||
try {
|
setSettings(original);
|
||||||
const original = JSON.parse(originalSettingsSnapshot);
|
}, [resetToSnapshot, setSettings]);
|
||||||
setSettings(original);
|
|
||||||
setLocalIsDirty(false);
|
|
||||||
setIsDirty(false);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse original settings:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [originalSettingsSnapshot, setSettings, setIsDirty]);
|
|
||||||
|
|
||||||
// Override loading state when login is disabled
|
// Override loading state when login is disabled
|
||||||
const actualLoading = loginEnabled ? loading : false;
|
const actualLoading = loginEnabled ? loading : false;
|
||||||
@@ -372,21 +326,18 @@ export default function AdminGeneralSection() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Mark that we just saved - the snapshot will be updated when refetch completes
|
// Mark that we just saved - the snapshot will be updated when refetch completes
|
||||||
justSavedRef.current = true;
|
markSaved();
|
||||||
|
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
|
|
||||||
// Update local preference after successful save so the app reflects the saved logo style
|
// Update local preference after successful save so the app reflects the saved logo style
|
||||||
if (settings.ui?.logoStyle) {
|
if (settings.ui?.logoStyle) {
|
||||||
updatePreference('logoVariant', settings.ui.logoStyle);
|
updatePreference('logoVariant', settings.ui.logoStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear dirty state immediately (snapshot will be updated by effect when refetch completes)
|
|
||||||
setLocalIsDirty(false);
|
|
||||||
markClean();
|
markClean();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
justSavedRef.current = false;
|
|
||||||
alert({
|
alert({
|
||||||
alertType: 'error',
|
alertType: 'error',
|
||||||
title: t('admin.error', 'Error'),
|
title: t('admin.error', 'Error'),
|
||||||
@@ -877,24 +828,13 @@ export default function AdminGeneralSection() {
|
|||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Sticky Save Footer - only shows when there are changes */}
|
<SettingsStickyFooter
|
||||||
{isDirty && loginEnabled && (
|
isDirty={isDirty}
|
||||||
<div className="settings-sticky-footer">
|
saving={saving}
|
||||||
<Group justify="space-between" w="100%">
|
loginEnabled={loginEnabled}
|
||||||
<Text size="sm" c="dimmed">
|
onSave={handleSave}
|
||||||
{t('admin.settings.unsavedChanges.hint', 'You have unsaved changes')}
|
onDiscard={handleDiscard}
|
||||||
</Text>
|
/>
|
||||||
<Group gap="sm">
|
|
||||||
<Button variant="default" onClick={handleDiscard} size="sm">
|
|
||||||
{t('admin.settings.discard', 'Discard')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} loading={saving} size="sm">
|
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
|
|||||||
+23
-9
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TextInput, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
|
import { TextInput, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
|
|
||||||
@@ -41,11 +43,14 @@ export default function AdminLegalSection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled]);
|
}, [loginEnabled]);
|
||||||
|
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} 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;
|
const actualLoading = loginEnabled ? loading : false;
|
||||||
|
|
||||||
if (actualLoading) {
|
if (actualLoading) {
|
||||||
@@ -68,7 +78,8 @@ export default function AdminLegalSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
|
<Stack gap="lg" className="settings-section-content">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
|
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
|
||||||
@@ -175,12 +186,15 @@ export default function AdminLegalSection() {
|
|||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<SettingsStickyFooter
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
isDirty={isDirty}
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
saving={saving}
|
||||||
</Button>
|
loginEnabled={loginEnabled}
|
||||||
</Group>
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -188,6 +202,6 @@ export default function AdminLegalSection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-12
@@ -1,14 +1,17 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import EditableSecretField from '@app/components/shared/EditableSecretField';
|
import EditableSecretField from '@app/components/shared/EditableSecretField';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
|
|
||||||
interface MailSettingsData {
|
interface MailSettingsData {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -30,6 +33,7 @@ type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsDat
|
|||||||
export default function AdminMailSection() {
|
export default function AdminMailSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { loginEnabled } = useLoginRequired();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -42,11 +46,11 @@ export default function AdminMailSection() {
|
|||||||
isFieldPending,
|
isFieldPending,
|
||||||
} = useAdminSettings<MailSettingsData>({
|
} = useAdminSettings<MailSettingsData>({
|
||||||
sectionName: 'mail',
|
sectionName: 'mail',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<MailSettingsData & { _pending?: Partial<MailSettingsData> }> => {
|
||||||
const mailResponse = await apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail');
|
const mailResponse = await apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail');
|
||||||
return mailResponse.data || {};
|
return mailResponse.data || {};
|
||||||
},
|
},
|
||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings: MailSettingsData) => {
|
||||||
return {
|
return {
|
||||||
sectionData: settings,
|
sectionData: settings,
|
||||||
deltaSettings: {}
|
deltaSettings: {}
|
||||||
@@ -58,8 +62,11 @@ export default function AdminMailSection() {
|
|||||||
fetchSettings();
|
fetchSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -71,6 +78,11 @@ export default function AdminMailSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
const original = resetToSnapshot();
|
||||||
|
setSettings(original);
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" justify="center" h={200}>
|
<Stack align="center" justify="center" h={200}>
|
||||||
@@ -80,8 +92,9 @@ export default function AdminMailSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
<div>
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<div>
|
||||||
<Text fw={600} size="lg">{t('admin.settings.mail.title', 'Mail Configuration')}</Text>
|
<Text fw={600} size="lg">{t('admin.settings.mail.title', 'Mail Configuration')}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')}
|
{t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')}
|
||||||
@@ -203,12 +216,15 @@ export default function AdminMailSection() {
|
|||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<SettingsStickyFooter
|
||||||
<Button onClick={handleSave} loading={saving} size="sm">
|
isDirty={isDirty}
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
saving={saving}
|
||||||
</Button>
|
loginEnabled={loginEnabled}
|
||||||
</Group>
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -216,6 +232,6 @@ export default function AdminMailSection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-9
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { alert } from '@app/components/toast';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
|
|
||||||
@@ -38,11 +40,14 @@ export default function AdminPremiumSection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled]);
|
}, [loginEnabled]);
|
||||||
|
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} 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;
|
const actualLoading = loginEnabled ? loading : false;
|
||||||
|
|
||||||
if (actualLoading) {
|
if (actualLoading) {
|
||||||
@@ -65,7 +75,8 @@ export default function AdminPremiumSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
|
<Stack gap="lg" className="settings-section-content">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
|
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
|
||||||
@@ -135,12 +146,15 @@ export default function AdminPremiumSection() {
|
|||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<SettingsStickyFooter
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
isDirty={isDirty}
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
saving={saving}
|
||||||
</Button>
|
loginEnabled={loginEnabled}
|
||||||
</Group>
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -148,6 +162,6 @@ export default function AdminPremiumSection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-14
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
@@ -31,7 +33,7 @@ export default function AdminPrivacySection() {
|
|||||||
isFieldPending,
|
isFieldPending,
|
||||||
} = useAdminSettings<PrivacySettingsData>({
|
} = useAdminSettings<PrivacySettingsData>({
|
||||||
sectionName: 'privacy',
|
sectionName: 'privacy',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<PrivacySettingsData & { _pending?: Record<string, any> }> => {
|
||||||
const [metricsResponse, systemResponse] = await Promise.all([
|
const [metricsResponse, systemResponse] = await Promise.all([
|
||||||
apiClient.get('/api/v1/admin/settings/section/metrics'),
|
apiClient.get('/api/v1/admin/settings/section/metrics'),
|
||||||
apiClient.get('/api/v1/admin/settings/section/system')
|
apiClient.get('/api/v1/admin/settings/section/system')
|
||||||
@@ -40,14 +42,14 @@ export default function AdminPrivacySection() {
|
|||||||
const metrics = metricsResponse.data;
|
const metrics = metricsResponse.data;
|
||||||
const system = systemResponse.data;
|
const system = systemResponse.data;
|
||||||
|
|
||||||
const result: any = {
|
const result: PrivacySettingsData & { _pending?: Record<string, any> } = {
|
||||||
enableAnalytics: system.enableAnalytics || false,
|
enableAnalytics: system.enableAnalytics || false,
|
||||||
googleVisibility: system.googlevisibility || false,
|
googleVisibility: system.googlevisibility || false,
|
||||||
metricsEnabled: metrics.enabled || false
|
metricsEnabled: metrics.enabled || false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge pending blocks from both endpoints
|
// Merge pending blocks from both endpoints
|
||||||
const pendingBlock: any = {};
|
const pendingBlock: Record<string, any> = {};
|
||||||
if (system._pending?.enableAnalytics !== undefined) {
|
if (system._pending?.enableAnalytics !== undefined) {
|
||||||
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
|
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,7 @@ export default function AdminPrivacySection() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings: PrivacySettingsData) => {
|
||||||
const deltaSettings = {
|
const deltaSettings = {
|
||||||
'system.enableAnalytics': settings.enableAnalytics,
|
'system.enableAnalytics': settings.enableAnalytics,
|
||||||
'system.googlevisibility': settings.googleVisibility,
|
'system.googlevisibility': settings.googleVisibility,
|
||||||
@@ -84,12 +86,19 @@ export default function AdminPrivacySection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled, fetchSettings]);
|
}, [loginEnabled, fetchSettings]);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
const original = resetToSnapshot();
|
||||||
|
setSettings(original);
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -113,7 +122,8 @@ export default function AdminPrivacySection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
|
<Stack gap="lg" className="settings-section-content">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -200,12 +210,15 @@ export default function AdminPrivacySection() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Save Button */}
|
</Stack>
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
<SettingsStickyFooter
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
isDirty={isDirty}
|
||||||
</Button>
|
saving={saving}
|
||||||
</Group>
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -213,6 +226,6 @@ export default function AdminPrivacySection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-15
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { alert } from '@app/components/toast';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
|
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
@@ -62,7 +64,7 @@ export default function AdminSecuritySection() {
|
|||||||
isFieldPending,
|
isFieldPending,
|
||||||
} = useAdminSettings<SecuritySettingsData>({
|
} = useAdminSettings<SecuritySettingsData>({
|
||||||
sectionName: 'security',
|
sectionName: 'security',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async (): Promise<SecuritySettingsData & { _pending?: Record<string, any> }> => {
|
||||||
const [securityResponse, premiumResponse, systemResponse] = await Promise.all([
|
const [securityResponse, premiumResponse, systemResponse] = await Promise.all([
|
||||||
apiClient.get('/api/v1/admin/settings/section/security'),
|
apiClient.get('/api/v1/admin/settings/section/security'),
|
||||||
apiClient.get('/api/v1/admin/settings/section/premium'),
|
apiClient.get('/api/v1/admin/settings/section/premium'),
|
||||||
@@ -88,7 +90,7 @@ export default function AdminSecuritySection() {
|
|||||||
systemPending: JSON.parse(JSON.stringify(systemPending || {}))
|
systemPending: JSON.parse(JSON.stringify(systemPending || {}))
|
||||||
});
|
});
|
||||||
|
|
||||||
const combined: any = {
|
const combined: SecuritySettingsData & { _pending?: Record<string, any> } = {
|
||||||
...securityActive
|
...securityActive
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ export default function AdminSecuritySection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge all _pending blocks
|
// Merge all _pending blocks
|
||||||
const mergedPending: any = {};
|
const mergedPending: Record<string, any> = {};
|
||||||
if (securityPending) {
|
if (securityPending) {
|
||||||
Object.assign(mergedPending, securityPending);
|
Object.assign(mergedPending, securityPending);
|
||||||
}
|
}
|
||||||
@@ -120,7 +122,7 @@ export default function AdminSecuritySection() {
|
|||||||
|
|
||||||
return combined;
|
return combined;
|
||||||
},
|
},
|
||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings: SecuritySettingsData) => {
|
||||||
const { audit, html, ...securitySettings } = settings;
|
const { audit, html, ...securitySettings } = settings;
|
||||||
|
|
||||||
const deltaSettings: Record<string, any> = {
|
const deltaSettings: Record<string, any> = {
|
||||||
@@ -165,6 +167,8 @@ export default function AdminSecuritySection() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loginEnabled) {
|
if (loginEnabled) {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
@@ -181,6 +185,7 @@ export default function AdminSecuritySection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
markSaved();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -192,6 +197,11 @@ export default function AdminSecuritySection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
const original = resetToSnapshot();
|
||||||
|
setSettings(original);
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
|
|
||||||
if (actualLoading) {
|
if (actualLoading) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" justify="center" h={200}>
|
<Stack align="center" justify="center" h={200}>
|
||||||
@@ -201,8 +211,9 @@ export default function AdminSecuritySection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<div className="settings-section-container">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
|
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
|
||||||
@@ -803,13 +814,15 @@ export default function AdminSecuritySection() {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{/* Save Button */}
|
<SettingsStickyFooter
|
||||||
<Group justify="flex-end">
|
isDirty={isDirty}
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
saving={saving}
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
loginEnabled={loginEnabled}
|
||||||
</Button>
|
onSave={handleSave}
|
||||||
</Group>
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
{/* Restart Confirmation Modal */}
|
||||||
<RestartConfirmationModal
|
<RestartConfirmationModal
|
||||||
@@ -817,6 +830,6 @@ export default function AdminSecuritySection() {
|
|||||||
onClose={closeRestartModal}
|
onClose={closeRestartModal}
|
||||||
onRestart={restartServer}
|
onRestart={restartServer}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user