mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Settings display demo and login fix (#4884)
# Description of Changes <img width="1569" height="980" alt="image" src="https://github.com/user-attachments/assets/dca1c227-ed84-4393-97a1-e3ce6eb1620b" /> <img width="1596" height="935" alt="image" src="https://github.com/user-attachments/assets/2003e1be-034a-4cbb-869e-6d5d912ab61d" /> <img width="1543" height="997" alt="image" src="https://github.com/user-attachments/assets/fe0c4f4b-eeee-4db4-a041-e554f350255a" /> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
@@ -143,16 +143,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}
|
||||
// Allow navigation even when disabled - the content inside will be disabled
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
data-tour={`admin-${item.key}-nav`}
|
||||
>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
await accountService.changePassword(currentPassword, newPassword);
|
||||
await accountService.changePasswordOnLogin(currentPassword, newPassword);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -14,6 +15,7 @@ const LandingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||
const logoPath = useLogoPath();
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addFiles(files);
|
||||
@@ -72,7 +74,7 @@ const LandingPage = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
|
||||
src={logoPath}
|
||||
alt="Stirling PDF Logo"
|
||||
style={{
|
||||
height: 'auto',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface LoginRequiredBannerProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner component that displays when login mode is required but not enabled
|
||||
* Shows prominent warning that settings are read-only
|
||||
*/
|
||||
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
|
||||
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderLeft: '4px solid var(--mantine-color-blue-6)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} mt="xs" c="dimmed">
|
||||
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,6 @@ import React from 'react';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
|
||||
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
|
||||
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
|
||||
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
|
||||
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
|
||||
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
|
||||
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
|
||||
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
|
||||
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
||||
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
||||
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
|
||||
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -40,8 +28,8 @@ export interface ConfigColors {
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
_isAdmin: boolean = false,
|
||||
_runningEE: boolean = false,
|
||||
_loginEnabled: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
@@ -64,112 +52,5 @@ export const createConfigNavSections = (
|
||||
},
|
||||
];
|
||||
|
||||
// Add Admin sections if user is admin
|
||||
if (isAdmin) {
|
||||
// Configuration
|
||||
sections.push({
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{
|
||||
key: 'adminGeneral',
|
||||
label: 'System Settings',
|
||||
icon: 'settings-rounded',
|
||||
component: <AdminGeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'adminFeatures',
|
||||
label: 'Features',
|
||||
icon: 'extension-rounded',
|
||||
component: <AdminFeaturesSection />
|
||||
},
|
||||
{
|
||||
key: 'adminEndpoints',
|
||||
label: 'Endpoints',
|
||||
icon: 'api-rounded',
|
||||
component: <AdminEndpointsSection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAdvanced',
|
||||
label: 'Advanced',
|
||||
icon: 'tune-rounded',
|
||||
component: <AdminAdvancedSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: 'Security & Authentication',
|
||||
items: [
|
||||
{
|
||||
key: 'adminSecurity',
|
||||
label: 'Security',
|
||||
icon: 'shield-rounded',
|
||||
component: <AdminSecuritySection />
|
||||
},
|
||||
{
|
||||
key: 'adminConnections',
|
||||
label: 'Connections',
|
||||
icon: 'link-rounded',
|
||||
component: <AdminConnectionsSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Licensing & Analytics
|
||||
sections.push({
|
||||
title: 'Licensing & Analytics',
|
||||
items: [
|
||||
{
|
||||
key: 'adminPremium',
|
||||
label: 'Premium',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPremiumSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAudit',
|
||||
label: 'Audit',
|
||||
icon: 'fact-check-rounded',
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
{
|
||||
key: 'adminUsage',
|
||||
label: 'Usage Analytics',
|
||||
icon: 'analytics-rounded',
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Policies & Privacy
|
||||
sections.push({
|
||||
title: 'Policies & Privacy',
|
||||
items: [
|
||||
{
|
||||
key: 'adminLegal',
|
||||
label: 'Legal',
|
||||
icon: 'gavel-rounded',
|
||||
component: <AdminLegalSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPrivacy',
|
||||
label: 'Privacy',
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
@@ -1,821 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput } 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface AdvancedSettingsData {
|
||||
enableAlphaFunctionality?: boolean;
|
||||
maxDPI?: number;
|
||||
enableUrlToPDF?: boolean;
|
||||
tessdataDir?: string;
|
||||
disableSanitize?: boolean;
|
||||
tempFileManagement?: {
|
||||
baseTmpDir?: string;
|
||||
libreofficeDir?: string;
|
||||
systemTempDir?: string;
|
||||
prefix?: string;
|
||||
maxAgeHours?: number;
|
||||
cleanupIntervalMinutes?: number;
|
||||
startupCleanup?: boolean;
|
||||
cleanupSystemTemp?: boolean;
|
||||
};
|
||||
processExecutor?: {
|
||||
sessionLimit?: {
|
||||
libreOfficeSessionLimit?: number;
|
||||
pdfToHtmlSessionLimit?: number;
|
||||
qpdfSessionLimit?: number;
|
||||
tesseractSessionLimit?: number;
|
||||
pythonOpenCvSessionLimit?: number;
|
||||
weasyPrintSessionLimit?: number;
|
||||
installAppSessionLimit?: number;
|
||||
calibreSessionLimit?: number;
|
||||
ghostscriptSessionLimit?: number;
|
||||
ocrMyPdfSessionLimit?: number;
|
||||
};
|
||||
timeoutMinutes?: {
|
||||
libreOfficetimeoutMinutes?: number;
|
||||
pdfToHtmltimeoutMinutes?: number;
|
||||
pythonOpenCvtimeoutMinutes?: number;
|
||||
weasyPrinttimeoutMinutes?: number;
|
||||
installApptimeoutMinutes?: number;
|
||||
calibretimeoutMinutes?: number;
|
||||
tesseractTimeoutMinutes?: number;
|
||||
qpdfTimeoutMinutes?: number;
|
||||
ghostscriptTimeoutMinutes?: number;
|
||||
ocrMyPdfTimeoutMinutes?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminAdvancedSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<AdvancedSettingsData>({
|
||||
sectionName: 'advanced',
|
||||
fetchTransformer: async () => {
|
||||
const [systemResponse, processExecutorResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/system'),
|
||||
apiClient.get('/api/v1/admin/settings/section/processExecutor')
|
||||
]);
|
||||
|
||||
const systemData = systemResponse.data || {};
|
||||
const processExecutorData = processExecutorResponse.data || {};
|
||||
|
||||
const result: any = {
|
||||
enableAlphaFunctionality: systemData.enableAlphaFunctionality || false,
|
||||
maxDPI: systemData.maxDPI || 0,
|
||||
enableUrlToPDF: systemData.enableUrlToPDF || false,
|
||||
tessdataDir: systemData.tessdataDir || '',
|
||||
disableSanitize: systemData.disableSanitize || false,
|
||||
tempFileManagement: systemData.tempFileManagement || {
|
||||
baseTmpDir: '',
|
||||
libreofficeDir: '',
|
||||
systemTempDir: '',
|
||||
prefix: 'stirling-pdf-',
|
||||
maxAgeHours: 24,
|
||||
cleanupIntervalMinutes: 30,
|
||||
startupCleanup: true,
|
||||
cleanupSystemTemp: false
|
||||
},
|
||||
processExecutor: processExecutorData || {}
|
||||
};
|
||||
|
||||
// Merge pending blocks from both endpoints
|
||||
const pendingBlock: any = {};
|
||||
if (systemData._pending?.enableAlphaFunctionality !== undefined) {
|
||||
pendingBlock.enableAlphaFunctionality = systemData._pending.enableAlphaFunctionality;
|
||||
}
|
||||
if (systemData._pending?.maxDPI !== undefined) {
|
||||
pendingBlock.maxDPI = systemData._pending.maxDPI;
|
||||
}
|
||||
if (systemData._pending?.enableUrlToPDF !== undefined) {
|
||||
pendingBlock.enableUrlToPDF = systemData._pending.enableUrlToPDF;
|
||||
}
|
||||
if (systemData._pending?.tessdataDir !== undefined) {
|
||||
pendingBlock.tessdataDir = systemData._pending.tessdataDir;
|
||||
}
|
||||
if (systemData._pending?.disableSanitize !== undefined) {
|
||||
pendingBlock.disableSanitize = systemData._pending.disableSanitize;
|
||||
}
|
||||
if (systemData._pending?.tempFileManagement) {
|
||||
pendingBlock.tempFileManagement = systemData._pending.tempFileManagement;
|
||||
}
|
||||
if (processExecutorData._pending) {
|
||||
pendingBlock.processExecutor = processExecutorData._pending;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
const deltaSettings: Record<string, any> = {
|
||||
'system.enableAlphaFunctionality': settings.enableAlphaFunctionality,
|
||||
'system.maxDPI': settings.maxDPI,
|
||||
'system.enableUrlToPDF': settings.enableUrlToPDF,
|
||||
'system.tessdataDir': settings.tessdataDir,
|
||||
'system.disableSanitize': settings.disableSanitize
|
||||
};
|
||||
|
||||
// Add temp file management settings
|
||||
if (settings.tempFileManagement) {
|
||||
deltaSettings['system.tempFileManagement.baseTmpDir'] = settings.tempFileManagement.baseTmpDir;
|
||||
deltaSettings['system.tempFileManagement.libreofficeDir'] = settings.tempFileManagement.libreofficeDir;
|
||||
deltaSettings['system.tempFileManagement.systemTempDir'] = settings.tempFileManagement.systemTempDir;
|
||||
deltaSettings['system.tempFileManagement.prefix'] = settings.tempFileManagement.prefix;
|
||||
deltaSettings['system.tempFileManagement.maxAgeHours'] = settings.tempFileManagement.maxAgeHours;
|
||||
deltaSettings['system.tempFileManagement.cleanupIntervalMinutes'] = settings.tempFileManagement.cleanupIntervalMinutes;
|
||||
deltaSettings['system.tempFileManagement.startupCleanup'] = settings.tempFileManagement.startupCleanup;
|
||||
deltaSettings['system.tempFileManagement.cleanupSystemTemp'] = settings.tempFileManagement.cleanupSystemTemp;
|
||||
}
|
||||
|
||||
// Add process executor settings
|
||||
if (settings.processExecutor?.sessionLimit) {
|
||||
Object.entries(settings.processExecutor.sessionLimit).forEach(([key, value]) => {
|
||||
deltaSettings[`processExecutor.sessionLimit.${key}`] = value;
|
||||
});
|
||||
}
|
||||
if (settings.processExecutor?.timeoutMinutes) {
|
||||
Object.entries(settings.processExecutor.timeoutMinutes).forEach(([key, value]) => {
|
||||
deltaSettings[`processExecutor.timeoutMinutes.${key}`] = value;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.advanced.description', 'Configure advanced features and experimental functionality.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Feature Flags */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.features', 'Feature Flags')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality.label', 'Enable Alpha Features')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableAlphaFunctionality || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableAlphaFunctionality')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF.label', 'Enable URL to PDF')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableUrlToPDF || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableUrlToPDF')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize.label', 'Disable HTML Sanitization')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.disableSanitize || false}
|
||||
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('disableSanitize')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Processing Settings */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.processing', 'Processing')}</Text>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.advanced.maxDPI.label', 'Maximum DPI')}</span>
|
||||
<PendingBadge show={isFieldPending('maxDPI')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.advanced.maxDPI.description', 'Maximum DPI for image processing (0 = unlimited)')}
|
||||
value={settings.maxDPI || 0}
|
||||
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
|
||||
min={0}
|
||||
max={3000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.advanced.tessdataDir.label', 'Tessdata Directory')}</span>
|
||||
<PendingBadge show={isFieldPending('tessdataDir')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.advanced.tessdataDir.description', 'Path to the directory containing Tessdata files for OCR')}
|
||||
value={settings.tessdataDir || ''}
|
||||
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
|
||||
placeholder="/usr/share/tessdata"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Temp File Management */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement.label', 'Temp File Management')}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('admin.settings.advanced.tempFileManagement.description', 'Configure temporary file storage and cleanup behavior')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.advanced.tempFileManagement.baseTmpDir.label', 'Base Temp Directory')}
|
||||
description={t('admin.settings.advanced.tempFileManagement.baseTmpDir.description', 'Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)')}
|
||||
value={settings.tempFileManagement?.baseTmpDir || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value }
|
||||
})}
|
||||
placeholder="Default: java.io.tmpdir/stirling-pdf"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.advanced.tempFileManagement.libreofficeDir.label', 'LibreOffice Temp Directory')}
|
||||
description={t('admin.settings.advanced.tempFileManagement.libreofficeDir.description', 'Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)')}
|
||||
value={settings.tempFileManagement?.libreofficeDir || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value }
|
||||
})}
|
||||
placeholder="Default: baseTmpDir/libreoffice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.advanced.tempFileManagement.systemTempDir.label', 'System Temp Directory')}
|
||||
description={t('admin.settings.advanced.tempFileManagement.systemTempDir.description', 'System temp directory to clean (only used if cleanupSystemTemp is enabled)')}
|
||||
value={settings.tempFileManagement?.systemTempDir || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value }
|
||||
})}
|
||||
placeholder="System temp directory path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.advanced.tempFileManagement.prefix.label', 'Temp File Prefix')}
|
||||
description={t('admin.settings.advanced.tempFileManagement.prefix.description', 'Prefix for temp file names')}
|
||||
value={settings.tempFileManagement?.prefix || 'stirling-pdf-'}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value }
|
||||
})}
|
||||
placeholder="stirling-pdf-"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.tempFileManagement.maxAgeHours.label', 'Max Age (hours)')}
|
||||
description={t('admin.settings.advanced.tempFileManagement.maxAgeHours.description', 'Maximum age in hours before temp files are cleaned up')}
|
||||
value={settings.tempFileManagement?.maxAgeHours ?? 24}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, maxAgeHours: Number(value) }
|
||||
})}
|
||||
min={1}
|
||||
max={720}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.label', 'Cleanup Interval (minutes)')}
|
||||
description={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.description', 'How often to run cleanup (in minutes)')}
|
||||
value={settings.tempFileManagement?.cleanupIntervalMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, cleanupIntervalMinutes: Number(value) }
|
||||
})}
|
||||
min={1}
|
||||
max={1440}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.startupCleanup.label', 'Startup Cleanup')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.startupCleanup ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.label', 'Cleanup System Temp')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.description', 'Whether to clean broader system temp directory (use with caution)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Process Executor Limits */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm">{t('admin.settings.advanced.processExecutor.label', 'Process Executor Limits')}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('admin.settings.advanced.processExecutor.description', 'Configure session limits and timeouts for each process executor')}
|
||||
</Text>
|
||||
|
||||
<Accordion variant="separated">
|
||||
{/* LibreOffice */}
|
||||
<Accordion.Item value="libreOffice">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.libreOffice', 'LibreOffice')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.libreOfficeSessionLimit ?? 1}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, libreOfficeSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.libreOfficetimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, libreOfficetimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* PDF to HTML */}
|
||||
<Accordion.Item value="pdfToHtml">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.pdfToHtml', 'PDF to HTML')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.pdfToHtmlSessionLimit ?? 1}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, pdfToHtmlSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.pdfToHtmltimeoutMinutes ?? 20}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, pdfToHtmltimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* QPDF */}
|
||||
<Accordion.Item value="qpdf">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.qpdf', 'QPDF')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.qpdfSessionLimit ?? 4}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, qpdfSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.qpdfTimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, qpdfTimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Tesseract OCR */}
|
||||
<Accordion.Item value="tesseract">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.tesseract', 'Tesseract OCR')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.tesseractSessionLimit ?? 1}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, tesseractSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.tesseractTimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, tesseractTimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Python OpenCV */}
|
||||
<Accordion.Item value="pythonOpenCv">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.pythonOpenCv', 'Python OpenCV')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.pythonOpenCvSessionLimit ?? 8}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, pythonOpenCvSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.pythonOpenCvtimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, pythonOpenCvtimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* WeasyPrint */}
|
||||
<Accordion.Item value="weasyPrint">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.weasyPrint', 'WeasyPrint')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.weasyPrintSessionLimit ?? 16}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, weasyPrintSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.weasyPrinttimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, weasyPrinttimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Install App */}
|
||||
<Accordion.Item value="installApp">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.installApp', 'Install App')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.installAppSessionLimit ?? 1}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, installAppSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.installApptimeoutMinutes ?? 60}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, installApptimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Calibre */}
|
||||
<Accordion.Item value="calibre">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.calibre', 'Calibre')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.calibreSessionLimit ?? 1}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, calibreSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.calibretimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, calibretimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Ghostscript */}
|
||||
<Accordion.Item value="ghostscript">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.ghostscript', 'Ghostscript')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.ghostscriptSessionLimit ?? 8}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, ghostscriptSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.ghostscriptTimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, ghostscriptTimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* OCRmyPDF */}
|
||||
<Accordion.Item value="ocrMyPdf">
|
||||
<Accordion.Control>{t('admin.settings.advanced.processExecutor.ocrMyPdf', 'OCRmyPDF')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
|
||||
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
|
||||
value={settings.processExecutor?.sessionLimit?.ocrMyPdfSessionLimit ?? 2}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
sessionLimit: { ...settings.processExecutor?.sessionLimit, ocrMyPdfSessionLimit: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
|
||||
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
|
||||
value={settings.processExecutor?.timeoutMinutes?.ocrMyPdfTimeoutMinutes ?? 30}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
processExecutor: {
|
||||
...settings.processExecutor,
|
||||
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, ocrMyPdfTimeoutMinutes: Number(value) }
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={240}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, Loader, Alert, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
import AuditSystemStatus from '@app/components/shared/config/configSections/audit/AuditSystemStatus';
|
||||
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
|
||||
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
|
||||
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
|
||||
|
||||
const AdminAuditSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const status = await auditService.getSystemStatus();
|
||||
setSystemStatus(status);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load audit system status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemStatus();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemStatus) {
|
||||
return (
|
||||
<Alert color="yellow" title={t('audit.notAvailable', 'Audit system not available')}>
|
||||
{t('audit.notAvailableMessage', 'The audit system is not configured or not available.')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<AuditSystemStatus status={systemStatus} />
|
||||
|
||||
{systemStatus.enabled ? (
|
||||
<Tabs defaultValue="dashboard">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="dashboard">
|
||||
{t('audit.tabs.dashboard', 'Dashboard')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events">
|
||||
{t('audit.tabs.events', 'Audit Events')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export">
|
||||
{t('audit.tabs.export', 'Export')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="dashboard" pt="md">
|
||||
<AuditChartsSection />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="events" pt="md">
|
||||
<AuditEventsTable />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="export" pt="md">
|
||||
<AuditExportSection />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Alert color="blue" title={t('audit.disabled', 'Audit logging is disabled')}>
|
||||
{t(
|
||||
'audit.disabledMessage',
|
||||
'Enable audit logging in your application configuration to track system events.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAuditSection;
|
||||
-409
@@ -1,409 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Loader, Group, Divider, Paper, Switch, 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
|
||||
import {
|
||||
ALL_PROVIDERS,
|
||||
Provider,
|
||||
} from '@app/components/shared/config/configSections/providerDefinitions';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface ConnectionsSettingsData {
|
||||
oauth2?: {
|
||||
enabled?: boolean;
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
provider?: string;
|
||||
autoCreateUser?: boolean;
|
||||
blockRegistration?: boolean;
|
||||
useAsUsername?: string;
|
||||
scopes?: string;
|
||||
client?: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
saml2?: {
|
||||
[key: string]: any;
|
||||
};
|
||||
mail?: {
|
||||
enabled?: boolean;
|
||||
enableInvites?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
from?: string;
|
||||
};
|
||||
ssoAutoLogin?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminConnectionsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
fetchSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<ConnectionsSettingsData>({
|
||||
sectionName: 'connections',
|
||||
fetchTransformer: async () => {
|
||||
// Fetch security settings (oauth2, saml2)
|
||||
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
|
||||
const securityData = securityResponse.data || {};
|
||||
|
||||
// Fetch mail settings
|
||||
const mailResponse = await apiClient.get('/api/v1/admin/settings/section/mail');
|
||||
const mailData = mailResponse.data || {};
|
||||
|
||||
// Fetch premium settings for SSO Auto Login
|
||||
const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium');
|
||||
const premiumData = premiumResponse.data || {};
|
||||
|
||||
const result: any = {
|
||||
oauth2: securityData.oauth2 || {},
|
||||
saml2: securityData.saml2 || {},
|
||||
mail: mailData || {},
|
||||
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false
|
||||
};
|
||||
|
||||
// Merge pending blocks from all three 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 (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
|
||||
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: () => {
|
||||
// This section doesn't have a global save button
|
||||
// Individual providers save through their own handlers
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings: {}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const isProviderConfigured = (provider: Provider): boolean => {
|
||||
if (provider.id === 'saml2') {
|
||||
return settings.saml2?.enabled === true;
|
||||
}
|
||||
|
||||
if (provider.id === 'smtp') {
|
||||
return settings.mail?.enabled === true;
|
||||
}
|
||||
|
||||
if (provider.id === 'oauth2-generic') {
|
||||
return settings.oauth2?.enabled === true;
|
||||
}
|
||||
|
||||
// Check if specific OAuth2 provider is configured (has clientId)
|
||||
const providerSettings = settings.oauth2?.client?.[provider.id];
|
||||
return !!(providerSettings?.clientId);
|
||||
};
|
||||
|
||||
const getProviderSettings = (provider: Provider): Record<string, any> => {
|
||||
if (provider.id === 'saml2') {
|
||||
return settings.saml2 || {};
|
||||
}
|
||||
|
||||
if (provider.id === 'smtp') {
|
||||
return settings.mail || {};
|
||||
}
|
||||
|
||||
if (provider.id === 'oauth2-generic') {
|
||||
// Generic OAuth2 settings are at the root oauth2 level
|
||||
return {
|
||||
enabled: settings.oauth2?.enabled,
|
||||
provider: settings.oauth2?.provider,
|
||||
issuer: settings.oauth2?.issuer,
|
||||
clientId: settings.oauth2?.clientId,
|
||||
clientSecret: settings.oauth2?.clientSecret,
|
||||
scopes: settings.oauth2?.scopes,
|
||||
useAsUsername: settings.oauth2?.useAsUsername,
|
||||
autoCreateUser: settings.oauth2?.autoCreateUser,
|
||||
blockRegistration: settings.oauth2?.blockRegistration,
|
||||
};
|
||||
}
|
||||
|
||||
// Specific OAuth2 provider settings
|
||||
return settings.oauth2?.client?.[provider.id] || {};
|
||||
};
|
||||
|
||||
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
|
||||
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 {
|
||||
// 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) => {
|
||||
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 {
|
||||
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 (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSSOAutoLoginSave = async () => {
|
||||
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 linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
|
||||
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('admin.settings.connections.title', 'Connections')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'admin.settings.connections.description',
|
||||
'Configure external authentication providers like OAuth2 and SAML.'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* SSO Auto Login - Premium Feature */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
|
||||
<Badge color="yellow" size="sm">PRO</Badge>
|
||||
</Group>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.connections.ssoAutoLogin.enable', 'Enable SSO Auto Login')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.connections.ssoAutoLogin.description', 'Automatically redirect to SSO login when authentication is required')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.ssoAutoLogin || false}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
||||
handleSSOAutoLoginSave();
|
||||
}}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Linked Services Section - Only show if there are linked providers */}
|
||||
{linkedProviders.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="md">
|
||||
{t('admin.settings.connections.linkedServices', 'Linked Services')}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{linkedProviders.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isConfigured={true}
|
||||
settings={getProviderSettings(provider)}
|
||||
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
|
||||
onDisconnect={() => handleProviderDisconnect(provider)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Divider between sections */}
|
||||
{availableProviders.length > 0 && <Divider />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unlinked Services Section */}
|
||||
{availableProviders.length > 0 && (
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="md">
|
||||
{t('admin.settings.connections.unlinkedServices', 'Unlinked Services')}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{availableProviders.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isConfigured={false}
|
||||
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, TextInput, PasswordInput, Select, 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface DatabaseSettingsData {
|
||||
enableCustomDatabase?: boolean;
|
||||
customDatabaseUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
type?: string;
|
||||
hostName?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function AdminDatabaseSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<DatabaseSettingsData>({
|
||||
sectionName: 'database',
|
||||
fetchTransformer: async () => {
|
||||
const response = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||
const systemData = response.data || {};
|
||||
|
||||
// Extract datasource from system response and handle pending
|
||||
const datasource = systemData.datasource || {
|
||||
enableCustomDatabase: false,
|
||||
customDatabaseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
type: 'postgresql',
|
||||
hostName: 'localhost',
|
||||
port: 5432,
|
||||
name: 'postgres'
|
||||
};
|
||||
|
||||
// Map pending changes from system._pending.datasource to root level
|
||||
const result: any = { ...datasource };
|
||||
if (systemData._pending?.datasource) {
|
||||
result._pending = systemData._pending.datasource;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
// Convert flat settings to dot-notation for delta endpoint
|
||||
const deltaSettings: Record<string, any> = {
|
||||
'system.datasource.enableCustomDatabase': settings.enableCustomDatabase,
|
||||
'system.datasource.customDatabaseUrl': settings.customDatabaseUrl,
|
||||
'system.datasource.username': settings.username,
|
||||
'system.datasource.password': settings.password,
|
||||
'system.datasource.type': settings.type,
|
||||
'system.datasource.hostName': settings.hostName,
|
||||
'system.datasource.port': settings.port,
|
||||
'system.datasource.name': settings.name
|
||||
};
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.database.title', 'Database')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.database.description', 'Configure custom database connection settings for enterprise deployments.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge color="grape" size="lg">ENTERPRISE</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.database.configuration', 'Database Configuration')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.database.enableCustom.label', 'Enable Custom Database')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableCustomDatabase || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableCustomDatabase: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{settings.enableCustomDatabase && (
|
||||
<>
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.database.customUrl.label', 'Custom Database URL')}</span>
|
||||
<PendingBadge show={isFieldPending('customDatabaseUrl')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')}
|
||||
value={settings.customDatabaseUrl || ''}
|
||||
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
|
||||
placeholder="jdbc:postgresql://localhost:5432/postgres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.database.type.label', 'Database Type')}</span>
|
||||
<PendingBadge show={isFieldPending('type')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
|
||||
value={settings.type || 'postgresql'}
|
||||
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
|
||||
data={[
|
||||
{ value: 'postgresql', label: 'PostgreSQL' },
|
||||
{ value: 'h2', label: 'H2' },
|
||||
{ value: 'mysql', label: 'MySQL' },
|
||||
{ value: 'mariadb', label: 'MariaDB' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.database.hostName.label', 'Host Name')}</span>
|
||||
<PendingBadge show={isFieldPending('hostName')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
|
||||
value={settings.hostName || ''}
|
||||
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.database.port.label', 'Port')}</span>
|
||||
<PendingBadge show={isFieldPending('port')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')}
|
||||
value={settings.port || 5432}
|
||||
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.database.name.label', 'Database Name')}</span>
|
||||
<PendingBadge show={isFieldPending('name')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
|
||||
value={settings.name || ''}
|
||||
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
|
||||
placeholder="postgres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.database.username.label', 'Username')}</span>
|
||||
<PendingBadge show={isFieldPending('username')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.database.username.description', 'Database authentication username')}
|
||||
value={settings.username || ''}
|
||||
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
|
||||
placeholder="postgres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.database.password.label', 'Password')}</span>
|
||||
<PendingBadge show={isFieldPending('password')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.database.password.description', 'Database authentication password')}
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Stack, Paper, Text, Loader, Group, 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
|
||||
interface EndpointsSettingsData {
|
||||
toRemove?: string[];
|
||||
groupsToRemove?: string[];
|
||||
}
|
||||
|
||||
export default function AdminEndpointsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<EndpointsSettingsData>({
|
||||
sectionName: 'endpoints',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Common endpoint examples
|
||||
const commonEndpoints = [
|
||||
'img-to-pdf',
|
||||
'pdf-to-img',
|
||||
'merge-pdfs',
|
||||
'split-pdf',
|
||||
'rotate-pdf',
|
||||
'compress-pdf',
|
||||
'extract-images',
|
||||
'extract-image-scans',
|
||||
'add-watermark',
|
||||
'remove-watermark',
|
||||
'add-password',
|
||||
'remove-password',
|
||||
'change-permissions',
|
||||
'ocr-pdf',
|
||||
'pdf-to-pdfa',
|
||||
'html-to-pdf',
|
||||
'url-to-pdf',
|
||||
'markdown-to-pdf',
|
||||
'get-info-on-pdf',
|
||||
'extract-pdf-metadata',
|
||||
'pdf-to-single-page',
|
||||
'crop',
|
||||
'auto-split-pdf',
|
||||
'sanitize-pdf',
|
||||
'add-page-numbers',
|
||||
'auto-rename',
|
||||
'scale-pages',
|
||||
'repair',
|
||||
'flatten',
|
||||
'remove-blanks',
|
||||
'compare-pdfs'
|
||||
];
|
||||
|
||||
// Common endpoint groups
|
||||
const commonGroups = [
|
||||
'Conversion',
|
||||
'Security',
|
||||
'Other',
|
||||
'Organize',
|
||||
'LibreOffice',
|
||||
'CLI',
|
||||
'Python',
|
||||
'OpenCV'
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.endpoints.description', 'Control which API endpoints and endpoint groups are available.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.endpoints.management', 'Endpoint Management')}</Text>
|
||||
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.endpoints.toRemove.label', 'Disabled Endpoints')}</span>
|
||||
<PendingBadge show={isFieldPending('toRemove')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
|
||||
value={settings.toRemove || []}
|
||||
onChange={(value) => setSettings({ ...settings, toRemove: value })}
|
||||
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select endpoints to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.endpoints.groupsToRemove.label', 'Disabled Endpoint Groups')}</span>
|
||||
<PendingBadge show={isFieldPending('groupsToRemove')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
|
||||
value={settings.groupsToRemove || []}
|
||||
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
|
||||
data={commonGroups.map(group => ({ value: group, label: group }))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select groups to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Paper bg="var(--mantine-color-blue-light)" p="sm" radius="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('admin.settings.endpoints.note', 'Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect.')}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface FeaturesSettingsData {
|
||||
serverCertificate?: {
|
||||
enabled?: boolean;
|
||||
organizationName?: string;
|
||||
validity?: number;
|
||||
regenerateOnStartup?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminFeaturesSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<FeaturesSettingsData>({
|
||||
sectionName: 'features',
|
||||
fetchTransformer: async () => {
|
||||
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
const result: any = {
|
||||
serverCertificate: systemData.serverCertificate || {
|
||||
enabled: true,
|
||||
organizationName: 'Stirling-PDF',
|
||||
validity: 365,
|
||||
regenerateOnStartup: false
|
||||
}
|
||||
};
|
||||
|
||||
// Map pending changes from system._pending.serverCertificate
|
||||
if (systemData._pending?.serverCertificate) {
|
||||
result._pending = { serverCertificate: systemData._pending.serverCertificate };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
const deltaSettings: Record<string, any> = {};
|
||||
|
||||
if (settings.serverCertificate) {
|
||||
deltaSettings['system.serverCertificate.enabled'] = settings.serverCertificate.enabled;
|
||||
deltaSettings['system.serverCertificate.organizationName'] = settings.serverCertificate.organizationName;
|
||||
deltaSettings['system.serverCertificate.validity'] = settings.serverCertificate.validity;
|
||||
deltaSettings['system.serverCertificate.regenerateOnStartup'] = settings.serverCertificate.regenerateOnStartup;
|
||||
}
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.features.description', 'Configure optional features and functionality.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Server Certificate - Pro Feature */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate.label', 'Server Certificate')}</Text>
|
||||
<Badge color="blue" size="sm">PRO</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('admin.settings.features.serverCertificate.description', 'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality')}
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled.label', 'Enable Server Certificate')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.enabled ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.features.serverCertificate.organizationName.label', 'Organization Name')}</span>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.organizationName')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.features.serverCertificate.organizationName.description', 'Organization name for generated certificates')}
|
||||
value={settings.serverCertificate?.organizationName || 'Stirling-PDF'}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
|
||||
})}
|
||||
placeholder="Stirling-PDF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.features.serverCertificate.validity.label', 'Certificate Validity (days)')}</span>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.validity')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.features.serverCertificate.validity.description', 'Number of days the certificate will be valid')}
|
||||
value={settings.serverCertificate?.validity ?? 365}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, validity: Number(value) }
|
||||
})}
|
||||
min={1}
|
||||
max={3650}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup.label', 'Regenerate on Startup')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface GeneralSettingsData {
|
||||
ui: {
|
||||
appNameNavbar?: string;
|
||||
languages?: string[];
|
||||
};
|
||||
system: {
|
||||
defaultLocale?: string;
|
||||
showUpdate?: boolean;
|
||||
showUpdateOnlyAdmin?: boolean;
|
||||
customHTMLFiles?: boolean;
|
||||
fileUploadLimit?: string;
|
||||
};
|
||||
customPaths?: {
|
||||
pipeline?: {
|
||||
watchedFoldersDir?: string;
|
||||
finishedFoldersDir?: string;
|
||||
};
|
||||
operations?: {
|
||||
weasyprint?: string;
|
||||
unoconvert?: string;
|
||||
};
|
||||
};
|
||||
customMetadata?: {
|
||||
autoUpdateMetadata?: boolean;
|
||||
author?: string;
|
||||
creator?: string;
|
||||
producer?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminGeneralSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<GeneralSettingsData>({
|
||||
sectionName: 'general',
|
||||
fetchTransformer: async () => {
|
||||
const [uiResponse, systemResponse, premiumResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/ui'),
|
||||
apiClient.get('/api/v1/admin/settings/section/system'),
|
||||
apiClient.get('/api/v1/admin/settings/section/premium')
|
||||
]);
|
||||
|
||||
const ui = uiResponse.data || {};
|
||||
const system = systemResponse.data || {};
|
||||
const premium = premiumResponse.data || {};
|
||||
|
||||
const result: any = {
|
||||
ui,
|
||||
system,
|
||||
customPaths: system.customPaths || {
|
||||
pipeline: {
|
||||
watchedFoldersDir: '',
|
||||
finishedFoldersDir: ''
|
||||
},
|
||||
operations: {
|
||||
weasyprint: '',
|
||||
unoconvert: ''
|
||||
}
|
||||
},
|
||||
customMetadata: premium.proFeatures?.customMetadata || {
|
||||
autoUpdateMetadata: false,
|
||||
author: '',
|
||||
creator: '',
|
||||
producer: ''
|
||||
}
|
||||
};
|
||||
|
||||
// Merge pending blocks from all three endpoints
|
||||
const pendingBlock: any = {};
|
||||
if (ui._pending) {
|
||||
pendingBlock.ui = ui._pending;
|
||||
}
|
||||
if (system._pending) {
|
||||
pendingBlock.system = system._pending;
|
||||
}
|
||||
if (system._pending?.customPaths) {
|
||||
pendingBlock.customPaths = system._pending.customPaths;
|
||||
}
|
||||
if (premium._pending?.proFeatures?.customMetadata) {
|
||||
pendingBlock.customMetadata = premium._pending.proFeatures.customMetadata;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
const deltaSettings: Record<string, any> = {
|
||||
// UI settings
|
||||
'ui.appNameNavbar': settings.ui.appNameNavbar,
|
||||
'ui.languages': settings.ui.languages,
|
||||
// System settings
|
||||
'system.defaultLocale': settings.system.defaultLocale,
|
||||
'system.showUpdate': settings.system.showUpdate,
|
||||
'system.showUpdateOnlyAdmin': settings.system.showUpdateOnlyAdmin,
|
||||
'system.customHTMLFiles': settings.system.customHTMLFiles,
|
||||
'system.fileUploadLimit': settings.system.fileUploadLimit,
|
||||
// Premium custom metadata
|
||||
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
|
||||
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
|
||||
'premium.proFeatures.customMetadata.creator': settings.customMetadata?.creator,
|
||||
'premium.proFeatures.customMetadata.producer': settings.customMetadata?.producer
|
||||
};
|
||||
|
||||
if (settings.customPaths) {
|
||||
deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths.pipeline?.watchedFoldersDir;
|
||||
deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths.pipeline?.finishedFoldersDir;
|
||||
deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths.operations?.weasyprint;
|
||||
deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths.operations?.unoconvert;
|
||||
}
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.general.title', 'System Settings')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.general.description', 'Configure system-wide application settings including branding and default behaviour.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* System Settings */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.system', 'System')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.appNameNavbar.label', 'Navbar Brand')}</span>
|
||||
<PendingBadge show={isFieldPending('ui.appNameNavbar')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')}
|
||||
value={settings.ui.appNameNavbar || ''}
|
||||
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })}
|
||||
placeholder="Stirling PDF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.languages.label', 'Available Languages')}</span>
|
||||
<PendingBadge show={isFieldPending('ui.languages')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
|
||||
value={settings.ui.languages || []}
|
||||
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
|
||||
data={[
|
||||
{ value: 'de_DE', label: 'Deutsch' },
|
||||
{ value: 'es_ES', label: 'Español' },
|
||||
{ value: 'fr_FR', label: 'Français' },
|
||||
{ value: 'it_IT', label: 'Italiano' },
|
||||
{ value: 'pl_PL', label: 'Polski' },
|
||||
{ value: 'pt_BR', label: 'Português (Brasil)' },
|
||||
{ value: 'ru_RU', label: 'Русский' },
|
||||
{ value: 'zh_CN', label: '简体中文' },
|
||||
{ value: 'ja_JP', label: '日本語' },
|
||||
{ value: 'ko_KR', label: '한국어' },
|
||||
]}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select languages"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.defaultLocale.label', 'Default Locale')}</span>
|
||||
<PendingBadge show={isFieldPending('system.defaultLocale')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
|
||||
value={settings.system.defaultLocale || ''}
|
||||
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
|
||||
placeholder="en_US"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.fileUploadLimit.label', 'File Upload Limit')}</span>
|
||||
<PendingBadge show={isFieldPending('system.fileUploadLimit')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')}
|
||||
value={settings.system.fileUploadLimit || ''}
|
||||
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })}
|
||||
placeholder="100MB"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.general.showUpdate.description', 'Display notifications when a new version is available')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.system.showUpdate || false}
|
||||
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('system.showUpdate')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin.label', 'Show Updates to Admins Only')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.general.showUpdateOnlyAdmin.description', 'Restrict update notifications to admin users only')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.system.showUpdateOnlyAdmin || false}
|
||||
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('system.showUpdateOnlyAdmin')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles.label', 'Custom HTML Files')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.general.customHTMLFiles.description', 'Allow serving custom HTML files from the customFiles directory')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.system.customHTMLFiles || false}
|
||||
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('system.customHTMLFiles')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Custom Metadata - Premium Feature */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata.label', 'Custom Metadata')}</Text>
|
||||
<Badge color="yellow" size="sm">PRO</Badge>
|
||||
</Group>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.general.customMetadata.autoUpdate.label', 'Auto Update Metadata')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.general.customMetadata.autoUpdate.description', 'Automatically update PDF metadata on all processed documents')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.customMetadata?.autoUpdateMetadata || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customMetadata: {
|
||||
...settings.customMetadata,
|
||||
autoUpdateMetadata: e.target.checked
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('customMetadata.autoUpdateMetadata')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.customMetadata.author.label', 'Default Author')}</span>
|
||||
<PendingBadge show={isFieldPending('customMetadata.author')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.customMetadata.author.description', 'Default author for PDF metadata (e.g., username)')}
|
||||
value={settings.customMetadata?.author || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customMetadata: {
|
||||
...settings.customMetadata,
|
||||
author: e.target.value
|
||||
}
|
||||
})}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.customMetadata.creator.label', 'Default Creator')}</span>
|
||||
<PendingBadge show={isFieldPending('customMetadata.creator')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.customMetadata.creator.description', 'Default creator for PDF metadata')}
|
||||
value={settings.customMetadata?.creator || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customMetadata: {
|
||||
...settings.customMetadata,
|
||||
creator: e.target.value
|
||||
}
|
||||
})}
|
||||
placeholder="Stirling-PDF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.customMetadata.producer.label', 'Default Producer')}</span>
|
||||
<PendingBadge show={isFieldPending('customMetadata.producer')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.customMetadata.producer.description', 'Default producer for PDF metadata')}
|
||||
value={settings.customMetadata?.producer || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customMetadata: {
|
||||
...settings.customMetadata,
|
||||
producer: e.target.value
|
||||
}
|
||||
})}
|
||||
placeholder="Stirling-PDF"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Custom Paths */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.customPaths.label', 'Custom Paths')}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('admin.settings.general.customPaths.description', 'Configure custom file system paths for pipeline processing and external tools')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text fw={500} size="sm" mt="xs">{t('admin.settings.general.customPaths.pipeline.label', 'Pipeline Directories')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.label', 'Watched Folders Directory')}</span>
|
||||
<PendingBadge show={isFieldPending('customPaths.pipeline.watchedFoldersDir')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.description', 'Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)')}
|
||||
value={settings.customPaths?.pipeline?.watchedFoldersDir || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customPaths: {
|
||||
...settings.customPaths,
|
||||
pipeline: {
|
||||
...settings.customPaths?.pipeline,
|
||||
watchedFoldersDir: e.target.value
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="/pipeline/watchedFolders"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.customPaths.pipeline.finishedFoldersDir.label', 'Finished Folders Directory')}</span>
|
||||
<PendingBadge show={isFieldPending('customPaths.pipeline.finishedFoldersDir')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.customPaths.pipeline.finishedFoldersDir.description', 'Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)')}
|
||||
value={settings.customPaths?.pipeline?.finishedFoldersDir || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customPaths: {
|
||||
...settings.customPaths,
|
||||
pipeline: {
|
||||
...settings.customPaths?.pipeline,
|
||||
finishedFoldersDir: e.target.value
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="/pipeline/finishedFolders"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text fw={500} size="sm" mt="md">{t('admin.settings.general.customPaths.operations.label', 'External Tool Paths')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.customPaths.operations.weasyprint.label', 'WeasyPrint Executable')}</span>
|
||||
<PendingBadge show={isFieldPending('customPaths.operations.weasyprint')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.customPaths.operations.weasyprint.description', 'Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)')}
|
||||
value={settings.customPaths?.operations?.weasyprint || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customPaths: {
|
||||
...settings.customPaths,
|
||||
operations: {
|
||||
...settings.customPaths?.operations,
|
||||
weasyprint: e.target.value
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="/opt/venv/bin/weasyprint"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.customPaths.operations.unoconvert.label', 'Unoconvert Executable')}</span>
|
||||
<PendingBadge show={isFieldPending('customPaths.operations.unoconvert')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.customPaths.operations.unoconvert.description', 'Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)')}
|
||||
value={settings.customPaths?.operations?.unoconvert || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customPaths: {
|
||||
...settings.customPaths,
|
||||
operations: {
|
||||
...settings.customPaths?.operations,
|
||||
unoconvert: e.target.value
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="/opt/venv/bin/unoconvert"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, Button, 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
|
||||
interface LegalSettingsData {
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
}
|
||||
|
||||
export default function AdminLegalSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<LegalSettingsData>({
|
||||
sectionName: 'legal',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.legal.description', 'Configure links to legal documents and policies.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Legal Disclaimer */}
|
||||
<Alert
|
||||
icon={<WarningIcon style={{ fontSize: 18 }} />}
|
||||
title={t('admin.settings.legal.disclaimer.title', 'Legal Responsibility Warning')}
|
||||
color="yellow"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'admin.settings.legal.disclaimer.message',
|
||||
'By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.legal.termsAndConditions.label', 'Terms and Conditions')}</span>
|
||||
<PendingBadge show={isFieldPending('termsAndConditions')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.legal.termsAndConditions.description', 'URL or filename to terms and conditions')}
|
||||
value={settings.termsAndConditions || ''}
|
||||
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
|
||||
placeholder="https://example.com/terms"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.legal.privacyPolicy.label', 'Privacy Policy')}</span>
|
||||
<PendingBadge show={isFieldPending('privacyPolicy')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.legal.privacyPolicy.description', 'URL or filename to privacy policy')}
|
||||
value={settings.privacyPolicy || ''}
|
||||
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
|
||||
placeholder="https://example.com/privacy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.legal.accessibilityStatement.label', 'Accessibility Statement')}</span>
|
||||
<PendingBadge show={isFieldPending('accessibilityStatement')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.legal.accessibilityStatement.description', 'URL or filename to accessibility statement')}
|
||||
value={settings.accessibilityStatement || ''}
|
||||
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
|
||||
placeholder="https://example.com/accessibility"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.legal.cookiePolicy.label', 'Cookie Policy')}</span>
|
||||
<PendingBadge show={isFieldPending('cookiePolicy')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.legal.cookiePolicy.description', 'URL or filename to cookie policy')}
|
||||
value={settings.cookiePolicy || ''}
|
||||
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
|
||||
placeholder="https://example.com/cookies"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.legal.impressum.label', 'Impressum')}</span>
|
||||
<PendingBadge show={isFieldPending('impressum')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.legal.impressum.description', 'URL or filename to impressum (required in some jurisdictions)')}
|
||||
value={settings.impressum || ''}
|
||||
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
|
||||
placeholder="https://example.com/impressum"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput } 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface MailSettingsData {
|
||||
enabled?: boolean;
|
||||
enableInvites?: boolean;
|
||||
inviteLinkExpiryHours?: number;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
from?: string;
|
||||
frontendUrl?: string;
|
||||
}
|
||||
|
||||
interface ApiResponseWithPending<T> {
|
||||
_pending?: Partial<T>;
|
||||
}
|
||||
|
||||
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
|
||||
type SystemApiResponse = { frontendUrl?: string } & ApiResponseWithPending<{ frontendUrl?: string }>;
|
||||
|
||||
export default function AdminMailSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<MailSettingsData>({
|
||||
sectionName: 'mail',
|
||||
fetchTransformer: async () => {
|
||||
const [mailResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail'),
|
||||
apiClient.get<SystemApiResponse>('/api/v1/admin/settings/section/system')
|
||||
]);
|
||||
|
||||
const mail = mailResponse.data || {};
|
||||
const system = systemResponse.data || {};
|
||||
|
||||
const result: MailSettingsData & ApiResponseWithPending<MailSettingsData> = {
|
||||
...mail,
|
||||
frontendUrl: system.frontendUrl || ''
|
||||
};
|
||||
|
||||
// Merge pending blocks from both endpoints
|
||||
const pendingBlock: Partial<MailSettingsData> = {};
|
||||
if (mail._pending) {
|
||||
Object.assign(pendingBlock, mail._pending);
|
||||
}
|
||||
if (system._pending?.frontendUrl !== undefined) {
|
||||
pendingBlock.frontendUrl = system._pending.frontendUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
const { frontendUrl, ...mailSettings } = settings;
|
||||
|
||||
const deltaSettings: Record<string, any> = {
|
||||
'system.frontendUrl': frontendUrl
|
||||
};
|
||||
|
||||
return {
|
||||
sectionData: mailSettings,
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<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.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.mail.enabled.label', 'Enable Mail')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enabled')} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.mail.enableInvites.label', 'Enable Email Invites')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableInvites || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableInvites: e.target.checked })}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableInvites')} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.mail.host.label', 'SMTP Host')}</span>
|
||||
<PendingBadge show={isFieldPending('host')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.mail.host.description', 'SMTP server hostname')}
|
||||
value={settings.host || ''}
|
||||
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.mail.port.label', 'SMTP Port')}</span>
|
||||
<PendingBadge show={isFieldPending('port')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.mail.port.description', 'SMTP server port (typically 587 for TLS, 465 for SSL)')}
|
||||
value={settings.port || 587}
|
||||
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.mail.username.label', 'SMTP Username')}</span>
|
||||
<PendingBadge show={isFieldPending('username')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.mail.username.description', 'SMTP authentication username')}
|
||||
value={settings.username || ''}
|
||||
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
|
||||
<PendingBadge show={isFieldPending('password')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.mail.from.label', 'From Address')}</span>
|
||||
<PendingBadge show={isFieldPending('from')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.mail.from.description', 'Email address to use as sender')}
|
||||
value={settings.from || ''}
|
||||
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.mail.frontendUrl.label', 'Frontend URL')}</span>
|
||||
<PendingBadge show={isFieldPending('frontendUrl')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.mail.frontendUrl.description', 'Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL.')}
|
||||
value={settings.frontendUrl || ''}
|
||||
onChange={(e) => setSettings({ ...settings, frontendUrl: e.target.value })}
|
||||
placeholder="https://pdf.example.com"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, Switch, Button, 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
|
||||
interface PremiumSettingsData {
|
||||
key?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminPremiumSection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<PremiumSettingsData>({
|
||||
sectionName: 'premium',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.premium.description', 'Configure your premium or enterprise license key.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Notice about moved features */}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
title={t('admin.settings.premium.movedFeatures.title', 'Premium Features Distributed')}
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.premium.movedFeatures.message', 'Premium and Enterprise features are now organized in their respective sections:')}
|
||||
</Text>
|
||||
<List mt="xs" size="sm">
|
||||
<List.Item><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></List.Item>
|
||||
<List.Item><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></List.Item>
|
||||
<List.Item><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></List.Item>
|
||||
<List.Item><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></List.Item>
|
||||
</List>
|
||||
</Alert>
|
||||
|
||||
{/* License Configuration */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License Configuration')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
|
||||
<PendingBadge show={isFieldPending('key')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key')}
|
||||
value={settings.key || ''}
|
||||
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.premium.enabled.label', 'Enable Premium Features')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Switch, Button, 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface PrivacySettingsData {
|
||||
enableAnalytics?: boolean;
|
||||
googleVisibility?: boolean;
|
||||
metricsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminPrivacySection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<PrivacySettingsData>({
|
||||
sectionName: 'privacy',
|
||||
fetchTransformer: async () => {
|
||||
const [metricsResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/metrics'),
|
||||
apiClient.get('/api/v1/admin/settings/section/system')
|
||||
]);
|
||||
|
||||
const metrics = metricsResponse.data;
|
||||
const system = systemResponse.data;
|
||||
|
||||
const result: any = {
|
||||
enableAnalytics: system.enableAnalytics || false,
|
||||
googleVisibility: system.googlevisibility || false,
|
||||
metricsEnabled: metrics.enabled || false
|
||||
};
|
||||
|
||||
// Merge pending blocks from both endpoints
|
||||
const pendingBlock: any = {};
|
||||
if (system._pending?.enableAnalytics !== undefined) {
|
||||
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
|
||||
}
|
||||
if (system._pending?.googlevisibility !== undefined) {
|
||||
pendingBlock.googleVisibility = system._pending.googlevisibility;
|
||||
}
|
||||
if (metrics._pending?.enabled !== undefined) {
|
||||
pendingBlock.metricsEnabled = metrics._pending.enabled;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
const deltaSettings = {
|
||||
'system.enableAnalytics': settings.enableAnalytics,
|
||||
'system.googlevisibility': settings.googleVisibility,
|
||||
'metrics.enabled': settings.metricsEnabled
|
||||
};
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.privacy.title', 'Privacy')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.privacy.description', 'Configure privacy and data collection settings.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Analytics & Tracking */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.analytics', 'Analytics & Tracking')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics.label', 'Enable Analytics')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableAnalytics || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableAnalytics')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled.label', 'Enable Metrics')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.metricsEnabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('metricsEnabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Search Engine Visibility */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.searchEngine', 'Search Engine Visibility')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility.label', 'Google Visibility')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.googleVisibility || false}
|
||||
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('googleVisibility')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
import { 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 { 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 PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface SecuritySettingsData {
|
||||
enableLogin?: boolean;
|
||||
csrfDisabled?: boolean;
|
||||
loginMethod?: string;
|
||||
loginAttemptCount?: number;
|
||||
loginResetTimeMinutes?: number;
|
||||
jwt?: {
|
||||
persistence?: boolean;
|
||||
enableKeyRotation?: boolean;
|
||||
enableKeyCleanup?: boolean;
|
||||
keyRetentionDays?: number;
|
||||
secureCookie?: boolean;
|
||||
};
|
||||
audit?: {
|
||||
enabled?: boolean;
|
||||
level?: number;
|
||||
retentionDays?: number;
|
||||
};
|
||||
html?: {
|
||||
urlSecurity?: {
|
||||
enabled?: boolean;
|
||||
level?: string;
|
||||
allowedDomains?: string[];
|
||||
blockedDomains?: string[];
|
||||
internalTlds?: string[];
|
||||
blockPrivateNetworks?: boolean;
|
||||
blockLocalhost?: boolean;
|
||||
blockLinkLocal?: boolean;
|
||||
blockCloudMetadata?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminSecuritySection() {
|
||||
const { t } = useTranslation();
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<SecuritySettingsData>({
|
||||
sectionName: 'security',
|
||||
fetchTransformer: async () => {
|
||||
const [securityResponse, premiumResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/security'),
|
||||
apiClient.get('/api/v1/admin/settings/section/premium'),
|
||||
apiClient.get('/api/v1/admin/settings/section/system')
|
||||
]);
|
||||
|
||||
const securityData = securityResponse.data || {};
|
||||
const premiumData = premiumResponse.data || {};
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
console.log('[AdminSecuritySection] Raw backend data:');
|
||||
console.log('Security:', JSON.parse(JSON.stringify(securityData)));
|
||||
console.log('Premium:', JSON.parse(JSON.stringify(premiumData)));
|
||||
console.log('System:', JSON.parse(JSON.stringify(systemData)));
|
||||
|
||||
const { _pending: securityPending, ...securityActive } = securityData;
|
||||
const { _pending: premiumPending, ...premiumActive } = premiumData;
|
||||
const { _pending: systemPending, ...systemActive } = systemData;
|
||||
|
||||
console.log('[AdminSecuritySection] Extracted pending blocks:', {
|
||||
securityPending: JSON.parse(JSON.stringify(securityPending || {})),
|
||||
premiumPending: JSON.parse(JSON.stringify(premiumPending || {})),
|
||||
systemPending: JSON.parse(JSON.stringify(systemPending || {}))
|
||||
});
|
||||
|
||||
const combined: any = {
|
||||
...securityActive
|
||||
};
|
||||
|
||||
// Only add audit if it exists (don't create defaults)
|
||||
if (premiumActive.enterpriseFeatures?.audit) {
|
||||
combined.audit = premiumActive.enterpriseFeatures.audit;
|
||||
}
|
||||
|
||||
// Only add html if it exists (don't create defaults)
|
||||
if (systemActive.html) {
|
||||
combined.html = systemActive.html;
|
||||
}
|
||||
|
||||
// Merge all _pending blocks
|
||||
const mergedPending: any = {};
|
||||
if (securityPending) {
|
||||
Object.assign(mergedPending, securityPending);
|
||||
}
|
||||
if (premiumPending?.enterpriseFeatures?.audit) {
|
||||
mergedPending.audit = premiumPending.enterpriseFeatures.audit;
|
||||
}
|
||||
if (systemPending?.html) {
|
||||
mergedPending.html = systemPending.html;
|
||||
}
|
||||
|
||||
if (Object.keys(mergedPending).length > 0) {
|
||||
combined._pending = mergedPending;
|
||||
}
|
||||
|
||||
return combined;
|
||||
},
|
||||
saveTransformer: (settings) => {
|
||||
const { audit, html, ...securitySettings } = settings;
|
||||
|
||||
const deltaSettings: Record<string, any> = {
|
||||
// Security settings
|
||||
'security.enableLogin': securitySettings.enableLogin,
|
||||
'security.csrfDisabled': securitySettings.csrfDisabled,
|
||||
'security.loginMethod': securitySettings.loginMethod,
|
||||
'security.loginAttemptCount': securitySettings.loginAttemptCount,
|
||||
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
|
||||
// JWT settings
|
||||
'security.jwt.persistence': securitySettings.jwt?.persistence,
|
||||
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
|
||||
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
|
||||
'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
|
||||
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
|
||||
// Premium audit settings
|
||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||
'premium.enterpriseFeatures.audit.level': audit?.level,
|
||||
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
|
||||
};
|
||||
|
||||
// System HTML settings
|
||||
if (html?.urlSecurity) {
|
||||
deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled;
|
||||
deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level;
|
||||
deltaSettings['system.html.urlSecurity.allowedDomains'] = html.urlSecurity.allowedDomains;
|
||||
deltaSettings['system.html.urlSecurity.blockedDomains'] = html.urlSecurity.blockedDomains;
|
||||
deltaSettings['system.html.urlSecurity.internalTlds'] = html.urlSecurity.internalTlds;
|
||||
deltaSettings['system.html.urlSecurity.blockPrivateNetworks'] = html.urlSecurity.blockPrivateNetworks;
|
||||
deltaSettings['system.html.urlSecurity.blockLocalhost'] = html.urlSecurity.blockLocalhost;
|
||||
deltaSettings['system.html.urlSecurity.blockLinkLocal'] = html.urlSecurity.blockLinkLocal;
|
||||
deltaSettings['system.html.urlSecurity.blockCloudMetadata'] = html.urlSecurity.blockCloudMetadata;
|
||||
}
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.security.description', 'Configure authentication, login behaviour, and security policies.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Authentication Settings */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.authentication', 'Authentication')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.enableLogin.label', 'Enable Login')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableLogin || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableLogin')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
label={t('admin.settings.security.loginMethod.label', 'Login Method')}
|
||||
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
|
||||
value={settings.loginMethod || 'all'}
|
||||
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
|
||||
data={[
|
||||
{ value: 'all', label: t('admin.settings.security.loginMethod.all', 'All Methods') },
|
||||
{ value: 'normal', label: t('admin.settings.security.loginMethod.normal', 'Username/Password Only') },
|
||||
{ value: 'oauth2', label: t('admin.settings.security.loginMethod.oauth2', 'OAuth2 Only') },
|
||||
{ value: 'saml2', label: t('admin.settings.security.loginMethod.saml2', 'SAML2 Only') },
|
||||
]}
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
{isFieldPending('loginMethod') && (
|
||||
<Group mt="xs">
|
||||
<PendingBadge show={true} />
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.loginAttemptCount.label', 'Login Attempt Limit')}</span>
|
||||
<PendingBadge show={isFieldPending('loginAttemptCount')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
|
||||
value={settings.loginAttemptCount || 0}
|
||||
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.loginResetTimeMinutes.label', 'Login Reset Time (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('loginResetTimeMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
|
||||
value={settings.loginResetTimeMinutes || 0}
|
||||
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
|
||||
min={0}
|
||||
max={1440}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled.label', 'Disable CSRF Protection')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.csrfDisabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('csrfDisabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* SSO/SAML Notice */}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
title={t('admin.settings.security.ssoNotice.title', 'Looking for SSO/SAML settings?')}
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.security.ssoNotice.message', 'OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* JWT Settings */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt.label', 'JWT Configuration')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence.label', 'Enable Key Persistence')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.persistence || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('jwt.persistence')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation.label', 'Enable Key Rotation')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.enableKeyRotation || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('jwt.enableKeyRotation')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup.label', 'Enable Key Cleanup')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.enableKeyCleanup || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('jwt.enableKeyCleanup')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
|
||||
value={settings.jwt?.keyRetentionDays || 7}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
|
||||
min={1}
|
||||
max={365}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie.label', 'Secure Cookie')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.secureCookie || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('jwt.secureCookie')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Audit Logging - Enterprise Feature */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">{t('admin.settings.security.audit.label', 'Audit Logging')}</Text>
|
||||
<Badge color="grape" size="sm">ENTERPRISE</Badge>
|
||||
</Group>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.audit.enabled.label', 'Enable Audit Logging')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.audit.enabled.description', 'Track user actions and system events for compliance and security monitoring')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.audit?.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('audit.enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.audit.level.label', 'Audit Level')}</span>
|
||||
<PendingBadge show={isFieldPending('audit.level')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
|
||||
value={settings.audit?.level || 2}
|
||||
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
|
||||
min={0}
|
||||
max={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.audit.retentionDays.label', 'Audit Retention (days)')}</span>
|
||||
<PendingBadge show={isFieldPending('audit.retentionDays')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
|
||||
value={settings.audit?.retentionDays || 90}
|
||||
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
|
||||
min={1}
|
||||
max={3650}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* HTML URL Security */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.htmlUrlSecurity.label', 'HTML URL Security')}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('admin.settings.security.htmlUrlSecurity.description', 'Configure URL access restrictions for HTML processing to prevent SSRF attacks')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.enabled.label', 'Enable URL Security')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.enabled || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.level.label', 'Security Level')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.level')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
|
||||
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, level: value || 'MEDIUM' }
|
||||
}
|
||||
})}
|
||||
data={[
|
||||
{ value: 'MAX', label: t('admin.settings.security.htmlUrlSecurity.level.max', 'Maximum (Whitelist Only)') },
|
||||
{ value: 'MEDIUM', label: t('admin.settings.security.htmlUrlSecurity.level.medium', 'Medium (Block Internal)') },
|
||||
{ value: 'OFF', label: t('admin.settings.security.htmlUrlSecurity.level.off', 'Off (No Restrictions)') },
|
||||
]}
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion variant="separated">
|
||||
<Accordion.Item value="advanced">
|
||||
<Accordion.Control>{t('admin.settings.security.htmlUrlSecurity.advanced', 'Advanced Settings')}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
{/* Allowed Domains */}
|
||||
<div>
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains.label', 'Allowed Domains (Whitelist)')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.allowedDomains')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
|
||||
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: {
|
||||
...settings.html?.urlSecurity,
|
||||
allowedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="cdn.example.com images.google.com"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blocked Domains */}
|
||||
<div>
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains.label', 'Blocked Domains (Blacklist)')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockedDomains')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
|
||||
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: {
|
||||
...settings.html?.urlSecurity,
|
||||
blockedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="malicious.com evil.org"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Internal TLDs */}
|
||||
<div>
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.security.htmlUrlSecurity.internalTlds.label', 'Internal TLDs')}</span>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.internalTlds')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
|
||||
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: {
|
||||
...settings.html?.urlSecurity,
|
||||
internalTlds: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder=".local .internal .corp .home"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Network Blocking Options */}
|
||||
<Text fw={600} size="sm" mt="md">{t('admin.settings.security.htmlUrlSecurity.networkBlocking', 'Network Blocking')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.label', 'Block Private Networks')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockPrivateNetworks')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.label', 'Block Localhost')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLocalhost || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockLocalhost')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.label', 'Block Link-Local Addresses')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockLinkLocal')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.label', 'Block Cloud Metadata Endpoints')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('html.urlSecurity.blockCloudMetadata')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Loader,
|
||||
Alert,
|
||||
Card,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
|
||||
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
|
||||
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
const AdminUsageSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
||||
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
|
||||
const response = await usageAnalyticsService.getEndpointStatistics(limit, dataType);
|
||||
|
||||
setData(response);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load usage statistics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [displayMode, dataType]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const getDisplayModeLabel = () => {
|
||||
switch (displayMode) {
|
||||
case 'top10':
|
||||
return t('usage.showing.top10', 'Top 10');
|
||||
case 'top20':
|
||||
return t('usage.showing.top20', 'Top 20');
|
||||
case 'all':
|
||||
return t('usage.showing.all', 'All');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Early returns for loading/error states
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('usage.error', 'Error loading usage statistics')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title={t('usage.noData', 'No data available')}>
|
||||
{t('usage.noDataMessage', 'No usage statistics are currently available.')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = data.endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
|
||||
|
||||
const displayedVisits = data.endpoints.reduce((sum, e) => sum + e.visits, 0);
|
||||
|
||||
const displayedPercentage = data.totalVisits > 0
|
||||
? ((displayedVisits / data.totalVisits) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Controls */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<SegmentedControl
|
||||
value={displayMode}
|
||||
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
|
||||
data={[
|
||||
{
|
||||
value: 'top10',
|
||||
label: t('usage.controls.top10', 'Top 10'),
|
||||
},
|
||||
{
|
||||
value: 'top20',
|
||||
label: t('usage.controls.top20', 'Top 20'),
|
||||
},
|
||||
{
|
||||
value: 'all',
|
||||
label: t('usage.controls.all', 'All'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
>
|
||||
{t('usage.controls.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('usage.controls.dataTypeLabel', 'Data Type:')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={dataType}
|
||||
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
|
||||
data={[
|
||||
{
|
||||
value: 'all',
|
||||
label: t('usage.controls.dataType.all', 'All'),
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: t('usage.controls.dataType.api', 'API'),
|
||||
},
|
||||
{
|
||||
value: 'ui',
|
||||
label: t('usage.controls.dataType.ui', 'UI'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Statistics Summary */}
|
||||
<Group gap="xl" style={{ flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalEndpoints}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.totalVisits', 'Total Visits')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalVisits.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.showing', 'Showing')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{getDisplayModeLabel()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.selectedVisits', 'Selected Visits')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Chart and Table */}
|
||||
<UsageAnalyticsChart data={chartData} />
|
||||
<UsageAnalyticsTable data={data.endpoints} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsageSection;
|
||||
@@ -10,6 +10,7 @@ interface ProviderCardProps {
|
||||
settings?: Record<string, any>;
|
||||
onSave?: (settings: Record<string, any>) => void;
|
||||
onDisconnect?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ProviderCard({
|
||||
@@ -18,6 +19,7 @@ export default function ProviderCard({
|
||||
settings = {},
|
||||
onSave,
|
||||
onDisconnect,
|
||||
disabled = false,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -39,6 +41,7 @@ export default function ProviderCard({
|
||||
};
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
if (disabled) return; // Block changes when disabled
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
@@ -63,6 +66,7 @@ export default function ProviderCard({
|
||||
<Switch
|
||||
checked={value || false}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -76,6 +80,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -88,6 +93,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -100,6 +106,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -174,11 +181,12 @@ export default function ProviderCard({
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('admin.settings.connections.disconnect', 'Disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert, Box, SimpleGrid } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditChartsData } from '@app/services/auditService';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
title: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'blue' }) => {
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{data.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" maw={200} style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '0.5rem',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: `var(--mantine-color-${color}-6)`,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AuditChartsSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChartsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await auditService.getChartsData(timePeriod);
|
||||
setChartsData(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load charts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChartsData();
|
||||
}, [timePeriod]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="center">
|
||||
<Loader size="lg" my="xl" />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('audit.charts.error', 'Error loading charts')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsByType.values[index],
|
||||
}));
|
||||
|
||||
const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsByUser.values[index],
|
||||
}));
|
||||
|
||||
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsOverTime.values[index],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.charts.title', 'Audit Dashboard')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={timePeriod}
|
||||
onChange={(value) => setTimePeriod(value as 'day' | 'week' | 'month')}
|
||||
data={[
|
||||
{ label: t('audit.charts.day', 'Day'), value: 'day' },
|
||||
{ label: t('audit.charts.week', 'Week'), value: 'week' },
|
||||
{ label: t('audit.charts.month', 'Month'), value: 'month' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={3} spacing="xl">
|
||||
<SimpleBarChart
|
||||
data={eventsByTypeData}
|
||||
title={t('audit.charts.byType', 'Events by Type')}
|
||||
color="blue"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsByUserData}
|
||||
title={t('audit.charts.byUser', 'Events by User')}
|
||||
color="green"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsOverTimeData}
|
||||
title={t('audit.charts.overTime', 'Events Over Time')}
|
||||
color="purple"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditChartsSection;
|
||||
@@ -1,229 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
Pagination,
|
||||
Modal,
|
||||
Code,
|
||||
Loader,
|
||||
Alert,
|
||||
Table,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditEvent } from '@app/services/auditService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useAuditFilters } from '@app/hooks/useAuditFilters';
|
||||
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
|
||||
|
||||
const AuditEventsTable: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({
|
||||
page: 0,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await auditService.getEvents({
|
||||
...filters,
|
||||
page: currentPage - 1,
|
||||
});
|
||||
setEvents(response.events);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEvents();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
// Wrap filter handlers to reset pagination
|
||||
const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => {
|
||||
handleFilterChange(key, value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleClearFiltersWithReset = () => {
|
||||
handleClearFilters();
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.events.title', 'Audit Events')}
|
||||
</Text>
|
||||
|
||||
{/* Filters */}
|
||||
<AuditFiltersForm
|
||||
filters={filters}
|
||||
eventTypes={eventTypes}
|
||||
users={users}
|
||||
onFilterChange={handleFilterChangeWithReset}
|
||||
onClearFilters={handleClearFiltersWithReset}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Loader size="lg" my="xl" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert color="red" title={t('audit.events.error', 'Error loading events')}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.type', 'Type')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.user', 'User')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.ipAddress', 'IP Address')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
|
||||
{t('audit.events.actions', 'Actions')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{events.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('audit.events.noEvents', 'No events found')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<Table.Tr key={event.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.eventType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.username}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.ipAddress}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
{t('audit.events.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
value={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
total={totalPages}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
<Modal
|
||||
opened={selectedEvent !== null}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
title={t('audit.events.eventDetails', 'Event Details')}
|
||||
size="lg"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{selectedEvent && (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
</Text>
|
||||
<Text size="sm">{formatDate(selectedEvent.timestamp)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.type', 'Type')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.eventType}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.user', 'User')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.username}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.ipAddress', 'IP Address')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.ipAddress}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.details', 'Details')}
|
||||
</Text>
|
||||
<Code block mah={300} style={{ overflow: 'auto' }}>
|
||||
{JSON.stringify(selectedEvent.details, null, 2)}
|
||||
</Code>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditEventsTable;
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService from '@app/services/auditService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useAuditFilters } from '@app/hooks/useAuditFilters';
|
||||
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
|
||||
|
||||
const AuditExportSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters();
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
|
||||
const blob = await auditService.exportData(exportFormat, filters);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
alert(t('audit.export.error', 'Failed to export data'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.export.title', 'Export Audit Data')}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'audit.export.description',
|
||||
'Export audit events to CSV or JSON format. Use filters to limit the exported data.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.export.format', 'Export Format')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={exportFormat}
|
||||
onChange={(value) => setExportFormat(value as 'csv' | 'json')}
|
||||
data={[
|
||||
{ label: 'CSV', value: 'csv' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.export.filters', 'Filters (Optional)')}
|
||||
</Text>
|
||||
<AuditFiltersForm
|
||||
filters={filters}
|
||||
eventTypes={eventTypes}
|
||||
users={users}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="download" width="1rem" height="1rem" />}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={exporting}
|
||||
>
|
||||
{t('audit.export.exportButton', 'Export Data')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditExportSection;
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Group, Select, Button } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuditFilters } from '@app/services/auditService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AuditFiltersFormProps {
|
||||
filters: AuditFilters;
|
||||
eventTypes: string[];
|
||||
users: string[];
|
||||
onFilterChange: (key: keyof AuditFilters, value: any) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared filter form for audit components
|
||||
*/
|
||||
const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
|
||||
filters,
|
||||
eventTypes,
|
||||
users,
|
||||
onFilterChange,
|
||||
onClearFilters,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByType', 'Filter by type')}
|
||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||
value={filters.eventType}
|
||||
onChange={(value) => onFilterChange('eventType', value || undefined)}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByUser', 'Filter by user')}
|
||||
data={users.map((user) => ({ value: user, label: user }))}
|
||||
value={filters.username}
|
||||
onChange={(value) => onFilterChange('username', value || undefined)}
|
||||
clearable
|
||||
searchable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.startDate', 'Start date')}
|
||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||
onChange={(value: string | null) =>
|
||||
onFilterChange('startDate', value ?? undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.endDate', 'End date')}
|
||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||
onChange={(value: string | null) =>
|
||||
onFilterChange('endDate', value ?? undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Button variant="outline" onClick={onClearFilters}>
|
||||
{t('audit.events.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditFiltersForm;
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, Group, Stack, Badge, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
|
||||
interface AuditSystemStatusProps {
|
||||
status: AuditStatus;
|
||||
}
|
||||
|
||||
const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.systemStatus.title', 'System Status')}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.status', 'Audit Logging')}
|
||||
</Text>
|
||||
<Badge color={status.enabled ? 'green' : 'red'} variant="light" size="lg" mt="xs">
|
||||
{status.enabled
|
||||
? t('audit.systemStatus.enabled', 'Enabled')
|
||||
: t('audit.systemStatus.disabled', 'Disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.level', 'Audit Level')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.level}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.retention', 'Retention Period')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.retentionDays} {t('audit.systemStatus.days', 'days')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.totalEvents', 'Total Events')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.totalEvents.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditSystemStatus;
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Stack, Group, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{data.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('usage.noData', 'No data available')}
|
||||
</Text>
|
||||
) : (
|
||||
data.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
maw="60%"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
({((item.value / maxValue) * 100).toFixed(1)}%)
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '0.5rem',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface UsageAnalyticsChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
}
|
||||
|
||||
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('usage.chart.title', 'Endpoint Usage Chart')}
|
||||
</Text>
|
||||
<SimpleBarChart data={data} maxValue={Math.max(...data.map((d) => d.value), 1)} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsChart;
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Stack, Table } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EndpointStatistic } from '@app/services/usageAnalyticsService';
|
||||
|
||||
interface UsageAnalyticsTableProps {
|
||||
data: EndpointStatistic[];
|
||||
}
|
||||
|
||||
const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('usage.table.title', 'Detailed Statistics')}
|
||||
</Text>
|
||||
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="5%">
|
||||
#
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="55%">
|
||||
{t('usage.table.endpoint', 'Endpoint')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
||||
{t('usage.table.visits', 'Visits')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
||||
{t('usage.table.percentage', 'Percentage')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('usage.table.noData', 'No data available')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
data.map((stat, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" truncate>
|
||||
{stat.endpoint}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" fw={600}>
|
||||
{stat.visits.toLocaleString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" c="dimmed">
|
||||
{stat.percentage.toFixed(2)}%
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsTable;
|
||||
Reference in New Issue
Block a user