mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-22 23:08:53 +02:00
settings menu reworks (#5864)
This commit is contained in:
@@ -652,6 +652,7 @@ description = "Configure external authentication providers like OAuth2 and SAML.
|
||||
disconnect = "Disconnect"
|
||||
disconnected = "Provider disconnected successfully"
|
||||
disconnectError = "Failed to disconnect provider"
|
||||
documentation = "View documentation"
|
||||
imageResolutionFull = "Full (Original Size)"
|
||||
imageResolutionReduced = "Reduced (Max 1200px)"
|
||||
linkedServices = "Linked Services"
|
||||
@@ -965,6 +966,18 @@ label = "Default Locale"
|
||||
description = "Maximum file upload size (e.g., 100MB, 1GB)"
|
||||
label = "File Upload Limit"
|
||||
|
||||
[admin.settings.general.hideDisabledTools]
|
||||
description = "Hide disabled tools from the interface"
|
||||
label = "Hide Disabled Tools"
|
||||
|
||||
[admin.settings.general.hideDisabledTools.googleDrive]
|
||||
description = "Hide Google Drive button when not enabled"
|
||||
label = "Hide Google Drive"
|
||||
|
||||
[admin.settings.general.hideDisabledTools.mobileScanner]
|
||||
description = "Hide mobile QR scanner button when not enabled"
|
||||
label = "Hide Mobile Scanner"
|
||||
|
||||
[admin.settings.general.frontendUrl]
|
||||
description = "Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL."
|
||||
label = "Frontend URL"
|
||||
|
||||
@@ -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>;
|
||||
onSave?: (settings: Record<string, any>) => void;
|
||||
onDisconnect?: () => void;
|
||||
onChange?: (settings: Record<string, any>) => void;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function ProviderCard({
|
||||
@@ -32,18 +34,19 @@ export default function ProviderCard({
|
||||
settings = {},
|
||||
onSave,
|
||||
onDisconnect,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
|
||||
|
||||
// Keep local settings in sync with incoming settings (values loaded from settings.yml)
|
||||
// Update whenever parent settings change, whether expanded or not (important for Discard to work)
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
setLocalSettings(settings);
|
||||
}
|
||||
}, [settings, expanded]);
|
||||
setLocalSettings(settings);
|
||||
}, [settings]);
|
||||
|
||||
// Initialize local settings with defaults when opening an unconfigured provider
|
||||
const handleConnectToggle = () => {
|
||||
@@ -63,7 +66,12 @@ export default function ProviderCard({
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
if (disabled) return; // Block changes when disabled
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
const updated = { ...localSettings, [key]: value };
|
||||
setLocalSettings(updated);
|
||||
// Notify parent of changes if onChange callback provided
|
||||
if (onChange) {
|
||||
onChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -225,22 +233,26 @@ export default function ProviderCard({
|
||||
<Stack gap="md" mt="xs">
|
||||
{provider.fields.map((field) => renderField(field))}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('admin.settings.connections.disconnect', 'Disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
{!readOnly && (onSave || onDisconnect) && (
|
||||
<Group justify="flex-end" mt="sm">
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('admin.settings.connections.disconnect', 'Disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
{onSave && (
|
||||
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
|
||||
79
frontend/src/core/hooks/useSettingsDirty.ts
Normal file
79
frontend/src/core/hooks/useSettingsDirty.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput, MultiSelect } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
@@ -70,7 +72,7 @@ export default function AdminAdvancedSection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<AdvancedSettingsData>({
|
||||
sectionName: 'advanced',
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<AdvancedSettingsData & { _pending?: Record<string, any> }> => {
|
||||
const [systemResponse, processExecutorResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/system'),
|
||||
apiClient.get('/api/v1/admin/settings/section/processExecutor')
|
||||
@@ -79,7 +81,7 @@ export default function AdminAdvancedSection() {
|
||||
const systemData = systemResponse.data || {};
|
||||
const processExecutorData = processExecutorResponse.data || {};
|
||||
|
||||
const result: any = {
|
||||
const result: AdvancedSettingsData & { _pending?: Record<string, any> } = {
|
||||
enableAlphaFunctionality: systemData.enableAlphaFunctionality || false,
|
||||
maxDPI: systemData.maxDPI || 0,
|
||||
enableUrlToPDF: systemData.enableUrlToPDF || false,
|
||||
@@ -99,7 +101,7 @@ export default function AdminAdvancedSection() {
|
||||
};
|
||||
|
||||
// Merge pending blocks from both endpoints
|
||||
const pendingBlock: any = {};
|
||||
const pendingBlock: Record<string, any> = {};
|
||||
if (systemData._pending?.enableAlphaFunctionality !== undefined) {
|
||||
pendingBlock.enableAlphaFunctionality = systemData._pending.enableAlphaFunctionality;
|
||||
}
|
||||
@@ -334,11 +336,14 @@ export default function AdminAdvancedSection() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -350,6 +355,11 @@ export default function AdminAdvancedSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
@@ -361,8 +371,9 @@ export default function AdminAdvancedSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -1092,12 +1103,15 @@ export default function AdminAdvancedSection() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -1105,6 +1119,6 @@ export default function AdminAdvancedSection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor, Select, Collapse } from '@mantine/core';
|
||||
@@ -7,7 +7,9 @@ import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
|
||||
import { Provider, useAllProviders } from '@app/components/shared/config/configSections/providerDefinitions';
|
||||
@@ -81,13 +83,13 @@ interface ConnectionsSettingsData {
|
||||
export default function AdminConnectionsSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
const { loginEnabled, getDisabledStyles } = useLoginRequired();
|
||||
const { restartModalOpened, closeRestartModal, restartServer } = useRestartServer();
|
||||
const allProviders = useAllProviders();
|
||||
|
||||
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
|
||||
sectionName: 'connections',
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<ConnectionsSettingsData & { _pending?: Record<string, any> }> => {
|
||||
// Fetch security settings (oauth2, saml2)
|
||||
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
|
||||
const securityData = securityResponse.data || {};
|
||||
@@ -108,7 +110,7 @@ export default function AdminConnectionsSection() {
|
||||
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
const result: any = {
|
||||
const result: ConnectionsSettingsData & { _pending?: Record<string, any> } = {
|
||||
oauth2: securityData.oauth2 || {},
|
||||
saml2: securityData.saml2 || {},
|
||||
mail: mailData || {},
|
||||
@@ -121,51 +123,93 @@ export default function AdminConnectionsSection() {
|
||||
mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false
|
||||
};
|
||||
|
||||
// Merge pending blocks from all four endpoints
|
||||
const pendingBlock: any = {};
|
||||
if (securityData._pending?.oauth2) {
|
||||
pendingBlock.oauth2 = securityData._pending.oauth2;
|
||||
}
|
||||
if (securityData._pending?.saml2) {
|
||||
pendingBlock.saml2 = securityData._pending.saml2;
|
||||
}
|
||||
if (mailData._pending) {
|
||||
pendingBlock.mail = mailData._pending;
|
||||
}
|
||||
if (telegramData._pending) {
|
||||
pendingBlock.telegram = telegramData._pending;
|
||||
}
|
||||
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
|
||||
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
|
||||
}
|
||||
if (systemData._pending?.enableMobileScanner !== undefined) {
|
||||
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined) {
|
||||
pendingBlock.mobileScannerConvertToPdf = systemData._pending.mobileScannerSettings.convertToPdf;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.imageResolution !== undefined) {
|
||||
pendingBlock.mobileScannerImageResolution = systemData._pending.mobileScannerSettings.imageResolution;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.pageFormat !== undefined) {
|
||||
pendingBlock.mobileScannerPageFormat = systemData._pending.mobileScannerSettings.pageFormat;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined) {
|
||||
pendingBlock.mobileScannerStretchToFit = systemData._pending.mobileScannerSettings.stretchToFit;
|
||||
}
|
||||
// Merge pending blocks from all endpoints - initialize with defaults to avoid warnings
|
||||
const pendingBlock: Record<string, any> = {
|
||||
oauth2: securityData._pending?.oauth2,
|
||||
saml2: securityData._pending?.saml2,
|
||||
mail: mailData._pending,
|
||||
telegram: telegramData._pending,
|
||||
ssoAutoLogin: premiumData._pending?.proFeatures?.ssoAutoLogin,
|
||||
enableMobileScanner: systemData._pending?.enableMobileScanner,
|
||||
mobileScannerConvertToPdf: systemData._pending?.mobileScannerSettings?.convertToPdf,
|
||||
mobileScannerImageResolution: systemData._pending?.mobileScannerSettings?.imageResolution,
|
||||
mobileScannerPageFormat: systemData._pending?.mobileScannerSettings?.pageFormat,
|
||||
mobileScannerStretchToFit: systemData._pending?.mobileScannerSettings?.stretchToFit,
|
||||
};
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
result._pending = pendingBlock;
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: () => {
|
||||
// This section doesn't have a global save button
|
||||
// Individual providers save through their own handlers
|
||||
saveTransformer: (currentSettings: ConnectionsSettingsData) => {
|
||||
const deltaSettings: Record<string, any> = {};
|
||||
|
||||
// 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 {
|
||||
sectionData: {},
|
||||
deltaSettings: {}
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -184,6 +228,26 @@ export default function AdminConnectionsSection() {
|
||||
}
|
||||
}, [loginEnabled, fetchSettings]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
markSaved();
|
||||
try {
|
||||
await adminSettings.saveSettings();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
@@ -241,175 +305,6 @@ export default function AdminConnectionsSection() {
|
||||
return settings?.oauth2?.client?.[provider.id] || {};
|
||||
};
|
||||
|
||||
const handleProviderSave = async (provider: Provider, providerSettings: Record<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) {
|
||||
return (
|
||||
@@ -419,105 +314,38 @@ export default function AdminConnectionsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleSSOAutoLoginSave = async () => {
|
||||
// Block save if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deltaSettings = {
|
||||
'premium.proFeatures.ssoAutoLogin': settings?.ssoAutoLogin
|
||||
};
|
||||
|
||||
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
||||
|
||||
if (response.status === 200) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('admin.success', 'Success'),
|
||||
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
|
||||
});
|
||||
showRestartModal();
|
||||
} else {
|
||||
throw new Error('Failed to save');
|
||||
}
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileScannerSave = async (newValue: boolean) => {
|
||||
// Block save if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deltaSettings = {
|
||||
'system.enableMobileScanner': newValue
|
||||
};
|
||||
|
||||
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
||||
|
||||
if (response.status === 200) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('admin.settings.success', 'Settings saved successfully')
|
||||
});
|
||||
fetchSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save mobile scanner setting:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.settings.error', 'Failed to save settings')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileScannerSettingsSave = async (settingKey: string, newValue: string | boolean) => {
|
||||
// Block save if login is disabled or mobile scanner is not enabled
|
||||
if (!validateLoginEnabled() || !settings?.enableMobileScanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deltaSettings = {
|
||||
[`system.mobileScannerSettings.${settingKey}`]: newValue
|
||||
};
|
||||
|
||||
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
||||
|
||||
if (response.status === 200) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('admin.success', 'Success'),
|
||||
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
|
||||
});
|
||||
showRestartModal();
|
||||
} else {
|
||||
throw new Error('Failed to save');
|
||||
}
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
|
||||
const availableProviders = allProviders.filter((p) => !isProviderConfigured(p));
|
||||
|
||||
const updateProviderSettings = (provider: Provider, updatedSettings: Record<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 (
|
||||
<Stack gap="xl">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="xl" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
@@ -561,7 +389,6 @@ export default function AdminConnectionsSection() {
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return; // Block change when login disabled
|
||||
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
||||
handleSSOAutoLoginSave();
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
@@ -598,9 +425,7 @@ export default function AdminConnectionsSection() {
|
||||
checked={settings?.enableMobileScanner || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return; // Block change when login disabled
|
||||
const newValue = e.target.checked;
|
||||
setSettings({ ...settings, enableMobileScanner: newValue });
|
||||
handleMobileScannerSave(newValue);
|
||||
setSettings({ ...settings, enableMobileScanner: e.target.checked });
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
@@ -625,9 +450,7 @@ export default function AdminConnectionsSection() {
|
||||
checked={settings?.mobileScannerConvertToPdf !== false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
const newValue = e.target.checked;
|
||||
setSettings({ ...settings, mobileScannerConvertToPdf: newValue });
|
||||
handleMobileScannerSettingsSave('convertToPdf', newValue);
|
||||
setSettings({ ...settings, mobileScannerConvertToPdf: e.target.checked });
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
@@ -652,7 +475,6 @@ export default function AdminConnectionsSection() {
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({ ...settings, mobileScannerImageResolution: value || 'full' });
|
||||
handleMobileScannerSettingsSave('imageResolution', value || 'full');
|
||||
}}
|
||||
data={[
|
||||
{ value: 'full', label: t('admin.settings.connections.imageResolutionFull', 'Full (Original Size)') },
|
||||
@@ -680,7 +502,6 @@ export default function AdminConnectionsSection() {
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({ ...settings, mobileScannerPageFormat: value || 'A4' });
|
||||
handleMobileScannerSettingsSave('pageFormat', value || 'A4');
|
||||
}}
|
||||
data={[
|
||||
{ value: 'keep', label: t('admin.settings.connections.pageFormatKeep', 'Keep (Original Dimensions)') },
|
||||
@@ -708,9 +529,7 @@ export default function AdminConnectionsSection() {
|
||||
checked={settings?.mobileScannerStretchToFit || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
const newValue = e.target.checked;
|
||||
setSettings({ ...settings, mobileScannerStretchToFit: newValue });
|
||||
handleMobileScannerSettingsSave('stretchToFit', newValue);
|
||||
setSettings({ ...settings, mobileScannerStretchToFit: e.target.checked });
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
@@ -738,8 +557,7 @@ export default function AdminConnectionsSection() {
|
||||
provider={provider}
|
||||
isConfigured={true}
|
||||
settings={getProviderSettings(provider)}
|
||||
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
|
||||
onDisconnect={() => handleProviderDisconnect(provider)}
|
||||
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
))}
|
||||
@@ -764,7 +582,7 @@ export default function AdminConnectionsSection() {
|
||||
provider={provider}
|
||||
isConfigured={false}
|
||||
settings={getProviderSettings(provider)}
|
||||
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
|
||||
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
))}
|
||||
@@ -778,6 +596,15 @@ export default function AdminConnectionsSection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={adminSettings.saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
NumberInput,
|
||||
@@ -25,7 +25,9 @@ import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import EditableSecretField from "@app/components/shared/EditableSecretField";
|
||||
@@ -66,7 +68,7 @@ export default function AdminDatabaseSection() {
|
||||
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
|
||||
useAdminSettings<DatabaseSettingsData>({
|
||||
sectionName: "database",
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<DatabaseSettingsData & { _pending?: Record<string, any> }> => {
|
||||
const response = await apiClient.get("/api/v1/admin/settings/section/system");
|
||||
const systemData = response.data || {};
|
||||
|
||||
@@ -83,14 +85,14 @@ export default function AdminDatabaseSection() {
|
||||
};
|
||||
|
||||
// Map pending changes from system._pending.datasource to root level
|
||||
const result: any = { ...datasource };
|
||||
const result: DatabaseSettingsData & { _pending?: Record<string, any> } = { ...datasource };
|
||||
if (systemData._pending?.datasource) {
|
||||
result._pending = systemData._pending.datasource;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
saveTransformer: (settings: DatabaseSettingsData) => {
|
||||
// Convert flat settings to dot-notation for delta endpoint
|
||||
const deltaSettings: Record<string, any> = {
|
||||
"system.datasource.enableCustomDatabase": settings.enableCustomDatabase,
|
||||
@@ -155,12 +157,15 @@ export default function AdminDatabaseSection() {
|
||||
loadBackupData();
|
||||
}, [loginEnabled, isEmbeddedH2, isCustomDatabase, datasourceType]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -172,6 +177,11 @@ export default function AdminDatabaseSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
if (!validateLoginEnabled()) return;
|
||||
setCreatingBackup(true);
|
||||
@@ -340,8 +350,9 @@ export default function AdminDatabaseSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
<div>
|
||||
<Group justify="space-between" align="center">
|
||||
@@ -532,13 +543,17 @@ export default function AdminDatabaseSection() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
||||
{t("admin.settings.save", "Save Changes")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" className="settings-section-content" style={{ marginTop: 0 }}>
|
||||
<Divider my="md" />
|
||||
|
||||
<Stack gap="md">
|
||||
@@ -795,6 +810,7 @@ export default function AdminDatabaseSection() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Stack, Paper, Text, Loader, Group, MultiSelect, Switch } from '@mantine/core';
|
||||
import { Stack, Paper, Text, Loader, Group, MultiSelect, Switch } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
|
||||
@@ -55,13 +57,25 @@ export default function AdminEndpointsSection() {
|
||||
}
|
||||
}, [loginEnabled, fetchSettings, fetchUiSettings]);
|
||||
|
||||
const { isDirty: isEndpointsDirty, resetToSnapshot: resetEndpointsSnapshot, markSaved: markEndpointsSaved } = useSettingsDirty(settings, loading);
|
||||
const { isDirty: isUiDirty, resetToSnapshot: resetUiSnapshot, markSaved: markUiSaved } = useSettingsDirty(uiSettings, uiLoading);
|
||||
|
||||
const isDirty = isEndpointsDirty || isUiDirty;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings();
|
||||
if (isEndpointsDirty) {
|
||||
markEndpointsSaved();
|
||||
await saveSettings();
|
||||
}
|
||||
if (isUiDirty) {
|
||||
markUiSaved();
|
||||
await saveUiSettings();
|
||||
}
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
@@ -72,26 +86,16 @@ export default function AdminEndpointsSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUiSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
const handleDiscard = useCallback(() => {
|
||||
if (isEndpointsDirty) {
|
||||
const original = resetEndpointsSnapshot();
|
||||
setSettings(original);
|
||||
}
|
||||
|
||||
try {
|
||||
await saveUiSettings();
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('admin.success', 'Success'),
|
||||
body: t('admin.settings.saveSuccess', 'Settings saved successfully. Restart required for changes to take effect.'),
|
||||
});
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
if (isUiDirty) {
|
||||
const original = resetUiSnapshot();
|
||||
setUiSettings(original);
|
||||
}
|
||||
};
|
||||
}, [isEndpointsDirty, isUiDirty, resetEndpointsSnapshot, resetUiSnapshot, setSettings, setUiSettings]);
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? (loading || uiLoading) : false;
|
||||
@@ -214,8 +218,9 @@ export default function AdminEndpointsSection() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
|
||||
@@ -276,12 +281,6 @@ export default function AdminEndpointsSection() {
|
||||
</Stack>
|
||||
</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">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
@@ -324,12 +323,15 @@ export default function AdminEndpointsSection() {
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleUiSave} loading={uiSaving} size="sm" disabled={!loginEnabled}>
|
||||
{t('admin.settings.save', 'Save User Defaults')}
|
||||
</Button>
|
||||
</Group>
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving || uiSaving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -337,6 +339,6 @@ export default function AdminEndpointsSection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core';
|
||||
import { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
@@ -36,11 +38,11 @@ export default function AdminFeaturesSection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<FeaturesSettingsData>({
|
||||
sectionName: 'features',
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<FeaturesSettingsData & { _pending?: Record<string, any> }> => {
|
||||
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
const result: any = {
|
||||
const result: FeaturesSettingsData & { _pending?: Record<string, any> } = {
|
||||
serverCertificate: systemData.serverCertificate || {
|
||||
enabled: true,
|
||||
organizationName: 'Stirling-PDF',
|
||||
@@ -56,7 +58,7 @@ export default function AdminFeaturesSection() {
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
saveTransformer: (settings: FeaturesSettingsData) => {
|
||||
const deltaSettings: Record<string, any> = {};
|
||||
|
||||
if (settings.serverCertificate) {
|
||||
@@ -79,11 +81,14 @@ export default function AdminFeaturesSection() {
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -95,6 +100,11 @@ export default function AdminFeaturesSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
@@ -106,8 +116,9 @@ export default function AdminFeaturesSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -223,13 +234,15 @@ export default function AdminFeaturesSection() {
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* Save Button */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -237,6 +250,6 @@ export default function AdminFeaturesSection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { TextInput, Textarea, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core';
|
||||
import { TextInput, Textarea, Switch, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
@@ -56,7 +58,7 @@ export default function AdminGeneralSection() {
|
||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { setIsDirty, markClean } = useUnsavedChanges();
|
||||
const { markClean } = useUnsavedChanges();
|
||||
const languageOptions = useMemo(
|
||||
() => Object.entries(supportedLanguages)
|
||||
.map(([code, label]) => ({ value: toUnderscoreFormat(code), label: `${label} (${code})` }))
|
||||
@@ -75,12 +77,6 @@ export default function AdminGeneralSection() {
|
||||
return uniquePaths;
|
||||
}, []);
|
||||
|
||||
// Track original settings for dirty detection
|
||||
const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState<string>('');
|
||||
const [isDirty, setLocalIsDirty] = useState(false);
|
||||
const isInitialLoad = useRef(true);
|
||||
const justSavedRef = useRef(false);
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
@@ -91,7 +87,7 @@ export default function AdminGeneralSection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<GeneralSettingsData>({
|
||||
sectionName: 'general',
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<GeneralSettingsData & { _pending?: Record<string, any> }> => {
|
||||
const [uiResponse, systemResponse, premiumResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/ui'),
|
||||
apiClient.get('/api/v1/admin/settings/section/system'),
|
||||
@@ -113,7 +109,7 @@ export default function AdminGeneralSection() {
|
||||
? watchedFoldersDirs
|
||||
: (pipelinePaths.watchedFoldersDir ? [pipelinePaths.watchedFoldersDir] : []);
|
||||
|
||||
const result: any = {
|
||||
const result: GeneralSettingsData & { _pending?: Record<string, any> } = {
|
||||
ui,
|
||||
system,
|
||||
customPaths: {
|
||||
@@ -140,7 +136,7 @@ export default function AdminGeneralSection() {
|
||||
};
|
||||
|
||||
// Merge pending blocks from all three endpoints
|
||||
const pendingBlock: any = {};
|
||||
const pendingBlock: Record<string, any> = {};
|
||||
if (ui._pending) {
|
||||
pendingBlock.ui = ui._pending;
|
||||
}
|
||||
@@ -160,7 +156,7 @@ export default function AdminGeneralSection() {
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
saveTransformer: (settings: GeneralSettingsData) => {
|
||||
const deltaSettings: Record<string, any> = {
|
||||
// UI settings
|
||||
'ui.appNameNavbar': settings.ui?.appNameNavbar,
|
||||
@@ -196,6 +192,8 @@ export default function AdminGeneralSection() {
|
||||
}
|
||||
});
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
|
||||
const selectedLanguages = useMemo(
|
||||
() => toUnderscoreLanguages(settings.ui?.languages || []),
|
||||
[settings.ui?.languages]
|
||||
@@ -266,49 +264,13 @@ export default function AdminGeneralSection() {
|
||||
}
|
||||
}, [loginEnabled, fetchSettings]);
|
||||
|
||||
// Snapshot original settings after initial load OR after successful save (when refetch completes)
|
||||
// Sync local preference with server setting on initial load
|
||||
useEffect(() => {
|
||||
if (loading || Object.keys(settings).length === 0) return;
|
||||
|
||||
// After initial load: set snapshot and sync preference
|
||||
if (isInitialLoad.current) {
|
||||
setOriginalSettingsSnapshot(JSON.stringify(settings));
|
||||
|
||||
// Sync local preference with server setting on initial load to ensure they're in sync
|
||||
// This ensures localStorage always reflects the server's authoritative value
|
||||
if (loginEnabled && settings.ui?.logoStyle) {
|
||||
updatePreference('logoVariant', settings.ui.logoStyle);
|
||||
}
|
||||
|
||||
isInitialLoad.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// After save: update snapshot to new server state so dirty tracking is accurate
|
||||
if (justSavedRef.current) {
|
||||
setOriginalSettingsSnapshot(JSON.stringify(settings));
|
||||
setLocalIsDirty(false);
|
||||
setIsDirty(false);
|
||||
justSavedRef.current = false;
|
||||
}
|
||||
}, [loading, settings, loginEnabled, updatePreference, setIsDirty]);
|
||||
if (loading || !loginEnabled || !settings.ui?.logoStyle) return;
|
||||
|
||||
// Track dirty state by comparing current settings to snapshot
|
||||
useEffect(() => {
|
||||
if (!originalSettingsSnapshot || loading) return;
|
||||
|
||||
const currentSnapshot = JSON.stringify(settings);
|
||||
const dirty = currentSnapshot !== originalSettingsSnapshot;
|
||||
setLocalIsDirty(dirty);
|
||||
setIsDirty(dirty);
|
||||
}, [settings, originalSettingsSnapshot, loading, setIsDirty]);
|
||||
|
||||
// Clean up dirty state on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsDirty(false);
|
||||
};
|
||||
}, [setIsDirty]);
|
||||
// This ensures localStorage always reflects the server's authoritative value
|
||||
updatePreference('logoVariant', settings.ui.logoStyle);
|
||||
}, [loading, loginEnabled, settings.ui?.logoStyle, updatePreference]);
|
||||
|
||||
// Handle hash navigation for deep linking to specific fields
|
||||
useEffect(() => {
|
||||
@@ -324,17 +286,9 @@ export default function AdminGeneralSection() {
|
||||
}, [location.hash, loading]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
if (originalSettingsSnapshot) {
|
||||
try {
|
||||
const original = JSON.parse(originalSettingsSnapshot);
|
||||
setSettings(original);
|
||||
setLocalIsDirty(false);
|
||||
setIsDirty(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse original settings:', e);
|
||||
}
|
||||
}
|
||||
}, [originalSettingsSnapshot, setSettings, setIsDirty]);
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
@@ -372,21 +326,18 @@ export default function AdminGeneralSection() {
|
||||
|
||||
try {
|
||||
// Mark that we just saved - the snapshot will be updated when refetch completes
|
||||
justSavedRef.current = true;
|
||||
|
||||
markSaved();
|
||||
|
||||
await saveSettings();
|
||||
|
||||
|
||||
// Update local preference after successful save so the app reflects the saved logo style
|
||||
if (settings.ui?.logoStyle) {
|
||||
updatePreference('logoVariant', settings.ui.logoStyle);
|
||||
}
|
||||
|
||||
// Clear dirty state immediately (snapshot will be updated by effect when refetch completes)
|
||||
setLocalIsDirty(false);
|
||||
|
||||
markClean();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
justSavedRef.current = false;
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
@@ -877,24 +828,13 @@ export default function AdminGeneralSection() {
|
||||
|
||||
</Stack>
|
||||
|
||||
{/* Sticky Save Footer - only shows when there are changes */}
|
||||
{isDirty && loginEnabled && (
|
||||
<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={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>
|
||||
)}
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
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 { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
|
||||
@@ -41,11 +43,14 @@ export default function AdminLegalSection() {
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -57,6 +62,11 @@ export default function AdminLegalSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
@@ -68,7 +78,8 @@ export default function AdminLegalSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
|
||||
@@ -175,12 +186,15 @@ export default function AdminLegalSection() {
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</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}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -188,6 +202,6 @@ export default function AdminLegalSection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Anchor } from '@mantine/core';
|
||||
import { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, Anchor } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import EditableSecretField from '@app/components/shared/EditableSecretField';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
|
||||
interface MailSettingsData {
|
||||
enabled?: boolean;
|
||||
@@ -30,6 +33,7 @@ type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsDat
|
||||
export default function AdminMailSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
@@ -42,11 +46,11 @@ export default function AdminMailSection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<MailSettingsData>({
|
||||
sectionName: 'mail',
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<MailSettingsData & { _pending?: Partial<MailSettingsData> }> => {
|
||||
const mailResponse = await apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail');
|
||||
return mailResponse.data || {};
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
saveTransformer: (settings: MailSettingsData) => {
|
||||
return {
|
||||
sectionData: settings,
|
||||
deltaSettings: {}
|
||||
@@ -58,8 +62,11 @@ export default function AdminMailSection() {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -71,6 +78,11 @@ export default function AdminMailSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
@@ -80,8 +92,9 @@ export default function AdminMailSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.mail.title', 'Mail Configuration')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')}
|
||||
@@ -203,12 +216,15 @@ export default function AdminMailSection() {
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -216,6 +232,6 @@ export default function AdminMailSection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert, List } from '@mantine/core';
|
||||
import { TextInput, Switch, Stack, Paper, Text, Loader, Group, Alert, List } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
|
||||
@@ -38,11 +40,14 @@ export default function AdminPremiumSection() {
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -54,6 +59,11 @@ export default function AdminPremiumSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
@@ -65,7 +75,8 @@ export default function AdminPremiumSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
|
||||
@@ -135,12 +146,15 @@ export default function AdminPremiumSection() {
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</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}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -148,6 +162,6 @@ export default function AdminPremiumSection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
|
||||
import { Switch, Stack, Paper, Text, Loader, Group } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
@@ -31,7 +33,7 @@ export default function AdminPrivacySection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<PrivacySettingsData>({
|
||||
sectionName: 'privacy',
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<PrivacySettingsData & { _pending?: Record<string, any> }> => {
|
||||
const [metricsResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/metrics'),
|
||||
apiClient.get('/api/v1/admin/settings/section/system')
|
||||
@@ -40,14 +42,14 @@ export default function AdminPrivacySection() {
|
||||
const metrics = metricsResponse.data;
|
||||
const system = systemResponse.data;
|
||||
|
||||
const result: any = {
|
||||
const result: PrivacySettingsData & { _pending?: Record<string, any> } = {
|
||||
enableAnalytics: system.enableAnalytics || false,
|
||||
googleVisibility: system.googlevisibility || false,
|
||||
metricsEnabled: metrics.enabled || false
|
||||
};
|
||||
|
||||
// Merge pending blocks from both endpoints
|
||||
const pendingBlock: any = {};
|
||||
const pendingBlock: Record<string, any> = {};
|
||||
if (system._pending?.enableAnalytics !== undefined) {
|
||||
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
|
||||
}
|
||||
@@ -64,7 +66,7 @@ export default function AdminPrivacySection() {
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
saveTransformer: (settings: PrivacySettingsData) => {
|
||||
const deltaSettings = {
|
||||
'system.enableAnalytics': settings.enableAnalytics,
|
||||
'system.googlevisibility': settings.googleVisibility,
|
||||
@@ -84,12 +86,19 @@ export default function AdminPrivacySection() {
|
||||
}
|
||||
}, [loginEnabled, fetchSettings]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -113,7 +122,8 @@ export default function AdminPrivacySection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
<div>
|
||||
@@ -200,12 +210,15 @@ export default function AdminPrivacySection() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -213,6 +226,6 @@ export default function AdminPrivacySection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, Alert, Badge, Accordion, Textarea } from '@mantine/core';
|
||||
import { NumberInput, Switch, Stack, Paper, Text, Loader, Group, Select, Alert, Badge, Accordion, Textarea } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
@@ -62,7 +64,7 @@ export default function AdminSecuritySection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<SecuritySettingsData>({
|
||||
sectionName: 'security',
|
||||
fetchTransformer: async () => {
|
||||
fetchTransformer: async (): Promise<SecuritySettingsData & { _pending?: Record<string, any> }> => {
|
||||
const [securityResponse, premiumResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/security'),
|
||||
apiClient.get('/api/v1/admin/settings/section/premium'),
|
||||
@@ -88,7 +90,7 @@ export default function AdminSecuritySection() {
|
||||
systemPending: JSON.parse(JSON.stringify(systemPending || {}))
|
||||
});
|
||||
|
||||
const combined: any = {
|
||||
const combined: SecuritySettingsData & { _pending?: Record<string, any> } = {
|
||||
...securityActive
|
||||
};
|
||||
|
||||
@@ -103,7 +105,7 @@ export default function AdminSecuritySection() {
|
||||
}
|
||||
|
||||
// Merge all _pending blocks
|
||||
const mergedPending: any = {};
|
||||
const mergedPending: Record<string, any> = {};
|
||||
if (securityPending) {
|
||||
Object.assign(mergedPending, securityPending);
|
||||
}
|
||||
@@ -120,7 +122,7 @@ export default function AdminSecuritySection() {
|
||||
|
||||
return combined;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
saveTransformer: (settings: SecuritySettingsData) => {
|
||||
const { audit, html, ...securitySettings } = settings;
|
||||
|
||||
const deltaSettings: Record<string, any> = {
|
||||
@@ -165,6 +167,8 @@ export default function AdminSecuritySection() {
|
||||
}
|
||||
});
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
@@ -181,6 +185,7 @@ export default function AdminSecuritySection() {
|
||||
}
|
||||
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
@@ -192,6 +197,11 @@ export default function AdminSecuritySection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
@@ -201,8 +211,9 @@ export default function AdminSecuritySection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
|
||||
@@ -803,13 +814,15 @@ export default function AdminSecuritySection() {
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* Save Button */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
@@ -817,6 +830,6 @@ export default function AdminSecuritySection() {
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user