mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
settings updates
This commit is contained in:
parent
82cf8cfde4
commit
634eb564d6
@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@ -51,6 +53,12 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
|
||||
this.licenseKeyChecker = licenseKeyChecker;
|
||||
}
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
@ -59,8 +67,13 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
private boolean hasProOrEnterpriseAccess() {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.PRO || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
return enabled && hasProOrEnterpriseAccess();
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
@ -73,6 +86,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
log.info("Server certificate feature requires Pro or Enterprise license");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
@ -88,6 +106,10 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException("Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
@ -114,6 +136,10 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException("Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
@ -174,6 +200,10 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException("Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
|
||||
@ -3250,7 +3250,28 @@
|
||||
"customHTMLFiles": "Custom HTML Files",
|
||||
"customHTMLFiles.description": "Allow serving custom HTML files from the customFiles directory",
|
||||
"languages": "Available Languages",
|
||||
"languages.description": "Languages that users can select from (leave empty to enable all languages)"
|
||||
"languages.description": "Languages that users can select from (leave empty to enable all languages)",
|
||||
"customMetadata": "Custom Metadata",
|
||||
"customMetadata.autoUpdate": "Auto Update Metadata",
|
||||
"customMetadata.autoUpdate.description": "Automatically update PDF metadata on all processed documents",
|
||||
"customMetadata.author": "Default Author",
|
||||
"customMetadata.author.description": "Default author for PDF metadata (e.g., username)",
|
||||
"customMetadata.creator": "Default Creator",
|
||||
"customMetadata.creator.description": "Default creator for PDF metadata",
|
||||
"customMetadata.producer": "Default Producer",
|
||||
"customMetadata.producer.description": "Default producer for PDF metadata",
|
||||
"customPaths": "Custom Paths",
|
||||
"customPaths.description": "Configure custom file system paths for pipeline processing and external tools",
|
||||
"customPaths.pipeline": "Pipeline Directories",
|
||||
"customPaths.pipeline.watchedFoldersDir": "Watched Folders Directory",
|
||||
"customPaths.pipeline.watchedFoldersDir.description": "Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)",
|
||||
"customPaths.pipeline.finishedFoldersDir": "Finished Folders Directory",
|
||||
"customPaths.pipeline.finishedFoldersDir.description": "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)",
|
||||
"customPaths.operations": "External Tool Paths",
|
||||
"customPaths.operations.weasyprint": "WeasyPrint Executable",
|
||||
"customPaths.operations.weasyprint.description": "Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)",
|
||||
"customPaths.operations.unoconvert": "Unoconvert Executable",
|
||||
"customPaths.operations.unoconvert.description": "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
@ -3289,7 +3310,39 @@
|
||||
"jwt.enableKeyRotation": "Enable Key Rotation",
|
||||
"jwt.enableKeyRotation.description": "Automatically rotate JWT signing keys periodically",
|
||||
"jwt.enableKeyCleanup": "Enable Key Cleanup",
|
||||
"jwt.enableKeyCleanup.description": "Automatically remove expired JWT keys"
|
||||
"jwt.enableKeyCleanup.description": "Automatically remove expired JWT keys",
|
||||
"audit": "Audit Logging",
|
||||
"audit.enabled": "Enable Audit Logging",
|
||||
"audit.enabled.description": "Track user actions and system events for compliance and security monitoring",
|
||||
"audit.level": "Audit Level",
|
||||
"audit.level.description": "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE",
|
||||
"audit.retentionDays": "Audit Retention (days)",
|
||||
"audit.retentionDays.description": "Number of days to retain audit logs",
|
||||
"htmlUrlSecurity": "HTML URL Security",
|
||||
"htmlUrlSecurity.description": "Configure URL access restrictions for HTML processing to prevent SSRF attacks",
|
||||
"htmlUrlSecurity.enabled": "Enable URL Security",
|
||||
"htmlUrlSecurity.enabled.description": "Enable URL security restrictions for HTML to PDF conversions",
|
||||
"htmlUrlSecurity.level": "Security Level",
|
||||
"htmlUrlSecurity.level.description": "MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions",
|
||||
"htmlUrlSecurity.level.max": "Maximum (Whitelist Only)",
|
||||
"htmlUrlSecurity.level.medium": "Medium (Block Internal)",
|
||||
"htmlUrlSecurity.level.off": "Off (No Restrictions)",
|
||||
"htmlUrlSecurity.advanced": "Advanced Settings",
|
||||
"htmlUrlSecurity.allowedDomains": "Allowed Domains (Whitelist)",
|
||||
"htmlUrlSecurity.allowedDomains.description": "One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX",
|
||||
"htmlUrlSecurity.blockedDomains": "Blocked Domains (Blacklist)",
|
||||
"htmlUrlSecurity.blockedDomains.description": "One domain per line (e.g., malicious.com). Additional domains to block",
|
||||
"htmlUrlSecurity.internalTlds": "Internal TLDs",
|
||||
"htmlUrlSecurity.internalTlds.description": "One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns",
|
||||
"htmlUrlSecurity.networkBlocking": "Network Blocking",
|
||||
"htmlUrlSecurity.blockPrivateNetworks": "Block Private Networks",
|
||||
"htmlUrlSecurity.blockPrivateNetworks.description": "Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)",
|
||||
"htmlUrlSecurity.blockLocalhost": "Block Localhost",
|
||||
"htmlUrlSecurity.blockLocalhost.description": "Block localhost and loopback addresses (127.x.x.x, ::1)",
|
||||
"htmlUrlSecurity.blockLinkLocal": "Block Link-Local Addresses",
|
||||
"htmlUrlSecurity.blockLinkLocal.description": "Block link-local addresses (169.254.x.x, fe80::/10)",
|
||||
"htmlUrlSecurity.blockCloudMetadata": "Block Cloud Metadata Endpoints",
|
||||
"htmlUrlSecurity.blockCloudMetadata.description": "Block cloud provider metadata endpoints (169.254.169.254)"
|
||||
},
|
||||
"connections": {
|
||||
"title": "Connections",
|
||||
@ -3300,6 +3353,9 @@
|
||||
"disconnect": "Disconnect",
|
||||
"disconnected": "Provider disconnected successfully",
|
||||
"disconnectError": "Failed to disconnect provider",
|
||||
"ssoAutoLogin": "SSO Auto Login",
|
||||
"ssoAutoLogin.enable": "Enable SSO Auto Login",
|
||||
"ssoAutoLogin.description": "Automatically redirect to SSO login when authentication is required",
|
||||
"oauth2": "OAuth2",
|
||||
"oauth2.enabled": "Enable OAuth2",
|
||||
"oauth2.enabled.description": "Allow users to authenticate using OAuth2 providers",
|
||||
@ -3331,6 +3387,27 @@
|
||||
"saml2.blockRegistration": "Block Registration",
|
||||
"saml2.blockRegistration.description": "Prevent new user registration via SAML2"
|
||||
},
|
||||
"database": {
|
||||
"title": "Database",
|
||||
"description": "Configure custom database connection settings for enterprise deployments.",
|
||||
"configuration": "Database Configuration",
|
||||
"enableCustom": "Enable Custom Database",
|
||||
"enableCustom.description": "Use your own custom database configuration instead of the default embedded database",
|
||||
"customUrl": "Custom Database URL",
|
||||
"customUrl.description": "Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.",
|
||||
"type": "Database Type",
|
||||
"type.description": "Type of database (not used if custom URL is provided)",
|
||||
"hostName": "Host Name",
|
||||
"hostName.description": "Database server hostname (not used if custom URL is provided)",
|
||||
"port": "Port",
|
||||
"port.description": "Database server port (not used if custom URL is provided)",
|
||||
"name": "Database Name",
|
||||
"name.description": "Name of the database (not used if custom URL is provided)",
|
||||
"username": "Username",
|
||||
"username.description": "Database authentication username",
|
||||
"password": "Password",
|
||||
"password.description": "Database authentication password"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy",
|
||||
"description": "Configure privacy and data collection settings.",
|
||||
@ -3360,7 +3437,41 @@
|
||||
"tessdataDir": "Tessdata Directory",
|
||||
"tessdataDir.description": "Path to the tessdata directory for OCR language files",
|
||||
"disableSanitize": "Disable HTML Sanitization",
|
||||
"disableSanitize.description": "WARNING: Security risk - disabling HTML sanitization can lead to XSS vulnerabilities"
|
||||
"disableSanitize.description": "WARNING: Security risk - disabling HTML sanitization can lead to XSS vulnerabilities",
|
||||
"tempFileManagement": "Temp File Management",
|
||||
"tempFileManagement.description": "Configure temporary file storage and cleanup behavior",
|
||||
"tempFileManagement.baseTmpDir": "Base Temp Directory",
|
||||
"tempFileManagement.baseTmpDir.description": "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)",
|
||||
"tempFileManagement.libreofficeDir": "LibreOffice Temp Directory",
|
||||
"tempFileManagement.libreofficeDir.description": "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)",
|
||||
"tempFileManagement.systemTempDir": "System Temp Directory",
|
||||
"tempFileManagement.systemTempDir.description": "System temp directory to clean (only used if cleanupSystemTemp is enabled)",
|
||||
"tempFileManagement.prefix": "Temp File Prefix",
|
||||
"tempFileManagement.prefix.description": "Prefix for temp file names",
|
||||
"tempFileManagement.maxAgeHours": "Max Age (hours)",
|
||||
"tempFileManagement.maxAgeHours.description": "Maximum age in hours before temp files are cleaned up",
|
||||
"tempFileManagement.cleanupIntervalMinutes": "Cleanup Interval (minutes)",
|
||||
"tempFileManagement.cleanupIntervalMinutes.description": "How often to run cleanup (in minutes)",
|
||||
"tempFileManagement.startupCleanup": "Startup Cleanup",
|
||||
"tempFileManagement.startupCleanup.description": "Clean up old temp files on application startup",
|
||||
"tempFileManagement.cleanupSystemTemp": "Cleanup System Temp",
|
||||
"tempFileManagement.cleanupSystemTemp.description": "Whether to clean broader system temp directory (use with caution)",
|
||||
"processExecutor": "Process Executor Limits",
|
||||
"processExecutor.description": "Configure session limits and timeouts for each process executor",
|
||||
"processExecutor.sessionLimit": "Session Limit",
|
||||
"processExecutor.sessionLimit.description": "Maximum concurrent instances",
|
||||
"processExecutor.timeout": "Timeout (minutes)",
|
||||
"processExecutor.timeout.description": "Maximum execution time",
|
||||
"processExecutor.libreOffice": "LibreOffice",
|
||||
"processExecutor.pdfToHtml": "PDF to HTML",
|
||||
"processExecutor.qpdf": "QPDF",
|
||||
"processExecutor.tesseract": "Tesseract OCR",
|
||||
"processExecutor.pythonOpenCv": "Python OpenCV",
|
||||
"processExecutor.weasyPrint": "WeasyPrint",
|
||||
"processExecutor.installApp": "Install App",
|
||||
"processExecutor.calibre": "Calibre",
|
||||
"processExecutor.ghostscript": "Ghostscript",
|
||||
"processExecutor.ocrMyPdf": "OCRmyPDF"
|
||||
},
|
||||
"mail": {
|
||||
"title": "Mail Server",
|
||||
@ -3395,30 +3506,30 @@
|
||||
},
|
||||
"premium": {
|
||||
"title": "Premium & Enterprise",
|
||||
"description": "Configure premium and enterprise features.",
|
||||
"license": "License",
|
||||
"description": "Configure your premium or enterprise license key.",
|
||||
"license": "License Configuration",
|
||||
"key": "License Key",
|
||||
"key.description": "Enter your premium or enterprise license key",
|
||||
"enabled": "Enable Premium Features",
|
||||
"enabled.description": "Enable license key checks for pro/enterprise features",
|
||||
"proFeatures": "Pro Features",
|
||||
"ssoAutoLogin": "SSO Auto Login",
|
||||
"ssoAutoLogin.description": "Automatically redirect to SSO login",
|
||||
"customMetadata.autoUpdate": "Auto Update Metadata",
|
||||
"customMetadata.autoUpdate.description": "Automatically update PDF metadata",
|
||||
"customMetadata.author": "Default Author",
|
||||
"customMetadata.author.description": "Default author for PDF metadata",
|
||||
"customMetadata.creator": "Default Creator",
|
||||
"customMetadata.creator.description": "Default creator for PDF metadata",
|
||||
"customMetadata.producer": "Default Producer",
|
||||
"customMetadata.producer.description": "Default producer for PDF metadata",
|
||||
"enterpriseFeatures": "Enterprise Features",
|
||||
"audit.enabled": "Enable Audit Logging",
|
||||
"audit.enabled.description": "Track user actions and system events",
|
||||
"audit.level": "Audit Level",
|
||||
"audit.level.description": "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE",
|
||||
"audit.retentionDays": "Audit Retention (days)",
|
||||
"audit.retentionDays.description": "Number of days to retain audit logs"
|
||||
"movedFeatures": {
|
||||
"title": "Premium Features Distributed",
|
||||
"message": "Premium and Enterprise features are now organized in their respective sections:"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"title": "Features",
|
||||
"description": "Configure optional features and functionality.",
|
||||
"serverCertificate": "Server Certificate",
|
||||
"serverCertificate.description": "Configure server-side certificate generation for \"Sign with Stirling-PDF\" functionality",
|
||||
"serverCertificate.enabled": "Enable Server Certificate",
|
||||
"serverCertificate.enabled.description": "Enable server-side certificate for \"Sign with Stirling-PDF\" option",
|
||||
"serverCertificate.organizationName": "Organization Name",
|
||||
"serverCertificate.organizationName.description": "Organization name for generated certificates",
|
||||
"serverCertificate.validity": "Certificate Validity (days)",
|
||||
"serverCertificate.validity.description": "Number of days the certificate will be valid",
|
||||
"serverCertificate.regenerateOnStartup": "Regenerate on Startup",
|
||||
"serverCertificate.regenerateOnStartup.description": "Generate new certificate on each application startup"
|
||||
},
|
||||
"endpoints": {
|
||||
"title": "API Endpoints",
|
||||
|
||||
@ -6,9 +6,11 @@ import AdminGeneralSection from './configSections/AdminGeneralSection';
|
||||
import AdminSecuritySection from './configSections/AdminSecuritySection';
|
||||
import AdminConnectionsSection from './configSections/AdminConnectionsSection';
|
||||
import AdminPrivacySection from './configSections/AdminPrivacySection';
|
||||
import AdminDatabaseSection from './configSections/AdminDatabaseSection';
|
||||
import AdminAdvancedSection from './configSections/AdminAdvancedSection';
|
||||
import AdminLegalSection from './configSections/AdminLegalSection';
|
||||
import AdminPremiumSection from './configSections/AdminPremiumSection';
|
||||
import AdminFeaturesSection from './configSections/AdminFeaturesSection';
|
||||
import AdminEndpointsSection from './configSections/AdminEndpointsSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
@ -104,12 +106,24 @@ export const createConfigNavSections = (
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPremium',
|
||||
label: 'Premium',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPremiumSection />
|
||||
},
|
||||
{
|
||||
key: 'adminFeatures',
|
||||
label: 'Features',
|
||||
icon: 'extension-rounded',
|
||||
component: <AdminFeaturesSection />
|
||||
},
|
||||
{
|
||||
key: 'adminEndpoints',
|
||||
label: 'Endpoints',
|
||||
|
||||
@ -11,6 +11,42 @@ interface AdvancedSettingsData {
|
||||
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() {
|
||||
@ -26,17 +62,32 @@ export default function AdminAdvancedSection() {
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/section/system');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings({
|
||||
enableAlphaFunctionality: data.enableAlphaFunctionality || false,
|
||||
maxDPI: data.maxDPI || 0,
|
||||
enableUrlToPDF: data.enableUrlToPDF || false,
|
||||
tessdataDir: data.tessdataDir || '',
|
||||
disableSanitize: data.disableSanitize || false
|
||||
});
|
||||
}
|
||||
const [systemResponse, processExecutorResponse] = await Promise.all([
|
||||
fetch('/api/v1/admin/settings/section/system'),
|
||||
fetch('/api/v1/admin/settings/section/processExecutor')
|
||||
]);
|
||||
|
||||
const systemData = systemResponse.ok ? await systemResponse.json() : {};
|
||||
const processExecutorData = processExecutorResponse.ok ? await processExecutorResponse.json() : {};
|
||||
|
||||
setSettings({
|
||||
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 || {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch advanced settings:', error);
|
||||
alert({
|
||||
@ -53,7 +104,7 @@ export default function AdminAdvancedSection() {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Use delta update endpoint with dot notation
|
||||
const deltaSettings = {
|
||||
const deltaSettings: Record<string, any> = {
|
||||
'system.enableAlphaFunctionality': settings.enableAlphaFunctionality,
|
||||
'system.maxDPI': settings.maxDPI,
|
||||
'system.enableUrlToPDF': settings.enableUrlToPDF,
|
||||
@ -61,6 +112,30 @@ export default function AdminAdvancedSection() {
|
||||
'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;
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -174,20 +249,510 @@ export default function AdminAdvancedSection() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Endpoints Info */}
|
||||
{/* Temp File Management */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Accordion variant="separated">
|
||||
<Accordion.Item value="endpoints">
|
||||
<Accordion.Control>
|
||||
{t('admin.settings.advanced.endpoints.manage', 'Manage API Endpoints')}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('admin.settings.advanced.endpoints.description', 'Endpoint management is configured via YAML. See documentation for details on enabling/disabling specific endpoints.')}
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement', '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', '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', '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', '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', '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', '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', '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', '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>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.startupCleanup ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp', '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>
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
</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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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', '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 */}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Loader, Group, Divider } from '@mantine/core';
|
||||
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge } from '@mantine/core';
|
||||
import { alert } from '../../../toast';
|
||||
import RestartConfirmationModal from '../RestartConfirmationModal';
|
||||
import { useRestartServer } from '../useRestartServer';
|
||||
@ -39,6 +39,7 @@ interface ConnectionsSettingsData {
|
||||
password?: string;
|
||||
from?: string;
|
||||
};
|
||||
ssoAutoLogin?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminConnectionsSection() {
|
||||
@ -61,10 +62,15 @@ export default function AdminConnectionsSection() {
|
||||
const mailResponse = await fetch('/api/v1/admin/settings/section/mail');
|
||||
const mailData = mailResponse.ok ? await mailResponse.json() : {};
|
||||
|
||||
// Fetch premium settings for SSO Auto Login
|
||||
const premiumResponse = await fetch('/api/v1/admin/settings/section/premium');
|
||||
const premiumData = premiumResponse.ok ? await premiumResponse.json() : {};
|
||||
|
||||
setSettings({
|
||||
oauth2: securityData.oauth2 || {},
|
||||
saml2: securityData.saml2 || {},
|
||||
mail: mailData || {}
|
||||
mail: mailData || {},
|
||||
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch connections settings:', error);
|
||||
@ -263,6 +269,37 @@ export default function AdminConnectionsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleSSOAutoLoginSave = async () => {
|
||||
try {
|
||||
const deltaSettings = {
|
||||
'premium.proFeatures.ssoAutoLogin': settings.ssoAutoLogin
|
||||
};
|
||||
|
||||
const response = await fetch('/api/v1/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: deltaSettings }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
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));
|
||||
|
||||
@ -281,6 +318,32 @@ export default function AdminConnectionsSection() {
|
||||
</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', '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>
|
||||
<Switch
|
||||
checked={settings.ssoAutoLogin || false}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
||||
handleSSOAutoLoginSave();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Linked Services Section - Only show if there are linked providers */}
|
||||
{linkedProviders.length > 0 && (
|
||||
<>
|
||||
|
||||
@ -0,0 +1,230 @@
|
||||
import { useState, 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 '../../../toast';
|
||||
import RestartConfirmationModal from '../RestartConfirmationModal';
|
||||
import { useRestartServer } from '../useRestartServer';
|
||||
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settings, setSettings] = useState<DatabaseSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/section/system');
|
||||
const systemData = response.ok ? await response.json() : {};
|
||||
|
||||
setSettings(systemData.datasource || {
|
||||
enableCustomDatabase: false,
|
||||
customDatabaseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
type: 'postgresql',
|
||||
hostName: 'localhost',
|
||||
port: 5432,
|
||||
name: 'postgres'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch database settings:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.fetchError', 'Failed to load settings'),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
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
|
||||
};
|
||||
|
||||
const response = await fetch('/api/v1/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: deltaSettings }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
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'),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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', '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>
|
||||
<Switch
|
||||
checked={settings.enableCustomDatabase || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableCustomDatabase: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.enableCustomDatabase && (
|
||||
<>
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.database.customUrl', 'Custom Database URL')}
|
||||
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={t('admin.settings.database.type', 'Database Type')}
|
||||
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={t('admin.settings.database.hostName', 'Host Name')}
|
||||
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={t('admin.settings.database.port', 'Port')}
|
||||
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={t('admin.settings.database.name', 'Database Name')}
|
||||
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={t('admin.settings.database.username', 'Username')}
|
||||
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={t('admin.settings.database.password', 'Password')}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,193 @@
|
||||
import { useState, 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 '../../../toast';
|
||||
import RestartConfirmationModal from '../RestartConfirmationModal';
|
||||
import { useRestartServer } from '../useRestartServer';
|
||||
|
||||
interface FeaturesSettingsData {
|
||||
serverCertificate?: {
|
||||
enabled?: boolean;
|
||||
organizationName?: string;
|
||||
validity?: number;
|
||||
regenerateOnStartup?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminFeaturesSection() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
const [settings, setSettings] = useState<FeaturesSettingsData>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const systemResponse = await fetch('/api/v1/admin/settings/section/system');
|
||||
const systemData = systemResponse.ok ? await systemResponse.json() : {};
|
||||
|
||||
setSettings({
|
||||
serverCertificate: systemData.serverCertificate || {
|
||||
enabled: true,
|
||||
organizationName: 'Stirling-PDF',
|
||||
validity: 365,
|
||||
regenerateOnStartup: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch features settings:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.fetchError', 'Failed to load settings'),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Save server certificate settings via delta endpoint
|
||||
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;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: deltaSettings }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
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'),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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', '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', '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>
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.enabled ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.features.serverCertificate.organizationName', 'Organization Name')}
|
||||
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={t('admin.settings.features.serverCertificate.validity', 'Certificate Validity (days)')}
|
||||
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', '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>
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
</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,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge } from '@mantine/core';
|
||||
import { alert } from '../../../toast';
|
||||
import RestartConfirmationModal from '../RestartConfirmationModal';
|
||||
import { useRestartServer } from '../useRestartServer';
|
||||
@ -17,6 +17,22 @@ interface GeneralSettingsData {
|
||||
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() {
|
||||
@ -36,16 +52,36 @@ export default function AdminGeneralSection() {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
// Fetch both ui and system sections from proprietary admin API
|
||||
const [uiResponse, systemResponse] = await Promise.all([
|
||||
const [uiResponse, systemResponse, premiumResponse] = await Promise.all([
|
||||
fetch('/api/v1/admin/settings/section/ui'),
|
||||
fetch('/api/v1/admin/settings/section/system')
|
||||
fetch('/api/v1/admin/settings/section/system'),
|
||||
fetch('/api/v1/admin/settings/section/premium')
|
||||
]);
|
||||
|
||||
if (uiResponse.ok && systemResponse.ok) {
|
||||
const ui = await uiResponse.json();
|
||||
const system = await systemResponse.json();
|
||||
setSettings({ ui, system });
|
||||
}
|
||||
const ui = uiResponse.ok ? await uiResponse.json() : {};
|
||||
const system = systemResponse.ok ? await systemResponse.json() : {};
|
||||
const premium = premiumResponse.ok ? await premiumResponse.json() : {};
|
||||
|
||||
setSettings({
|
||||
ui,
|
||||
system,
|
||||
customPaths: system.customPaths || {
|
||||
pipeline: {
|
||||
watchedFoldersDir: '',
|
||||
finishedFoldersDir: ''
|
||||
},
|
||||
operations: {
|
||||
weasyprint: '',
|
||||
unoconvert: ''
|
||||
}
|
||||
},
|
||||
customMetadata: premium.proFeatures?.customMetadata || {
|
||||
autoUpdateMetadata: false,
|
||||
author: '',
|
||||
creator: '',
|
||||
producer: ''
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch general settings:', error);
|
||||
alert({
|
||||
@ -75,7 +111,29 @@ export default function AdminGeneralSection() {
|
||||
})
|
||||
]);
|
||||
|
||||
if (uiResponse.ok && systemResponse.ok) {
|
||||
// Save custom metadata and custom paths via delta endpoint
|
||||
const deltaSettings: Record<string, any> = {
|
||||
'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
|
||||
};
|
||||
|
||||
// Add custom paths settings
|
||||
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;
|
||||
}
|
||||
|
||||
const deltaResponse = await fetch('/api/v1/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: deltaSettings }),
|
||||
});
|
||||
|
||||
if (uiResponse.ok && systemResponse.ok && deltaResponse.ok) {
|
||||
// Show restart confirmation modal
|
||||
showRestartModal();
|
||||
} else {
|
||||
@ -210,6 +268,175 @@ export default function AdminGeneralSection() {
|
||||
</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', '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', '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>
|
||||
<Switch
|
||||
checked={settings.customMetadata?.autoUpdateMetadata || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
customMetadata: {
|
||||
...settings.customMetadata,
|
||||
autoUpdateMetadata: e.target.checked
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.general.customMetadata.author', 'Default Author')}
|
||||
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={t('admin.settings.general.customMetadata.creator', 'Default Creator')}
|
||||
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={t('admin.settings.general.customMetadata.producer', 'Default Producer')}
|
||||
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', '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', 'Pipeline Directories')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.general.customPaths.pipeline.watchedFoldersDir', 'Watched Folders Directory')}
|
||||
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={t('admin.settings.general.customPaths.pipeline.finishedFoldersDir', 'Finished Folders Directory')}
|
||||
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', 'External Tool Paths')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.general.customPaths.operations.weasyprint', 'WeasyPrint Executable')}
|
||||
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={t('admin.settings.general.customPaths.operations.unoconvert', 'Unoconvert Executable')}
|
||||
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">
|
||||
|
||||
@ -1,29 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
|
||||
import { alert } from '../../../toast';
|
||||
import LocalIcon from '../../LocalIcon';
|
||||
import RestartConfirmationModal from '../RestartConfirmationModal';
|
||||
import { useRestartServer } from '../useRestartServer';
|
||||
|
||||
interface PremiumSettingsData {
|
||||
key?: string;
|
||||
enabled?: boolean;
|
||||
proFeatures?: {
|
||||
SSOAutoLogin?: boolean;
|
||||
CustomMetadata?: {
|
||||
autoUpdateMetadata?: boolean;
|
||||
author?: string;
|
||||
creator?: string;
|
||||
producer?: string;
|
||||
};
|
||||
};
|
||||
enterpriseFeatures?: {
|
||||
audit?: {
|
||||
enabled?: boolean;
|
||||
level?: number;
|
||||
retentionDays?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminPremiumSection() {
|
||||
@ -94,14 +79,32 @@ export default function AdminPremiumSection() {
|
||||
<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 premium and enterprise features.')}
|
||||
{t('admin.settings.premium.description', 'Configure your premium or enterprise license key.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* License */}
|
||||
{/* 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>
|
||||
<ul style={{ marginTop: '8px', marginBottom: 0, paddingLeft: '20px' }}>
|
||||
<li><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></li>
|
||||
<li><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></li>
|
||||
<li><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></li>
|
||||
<li><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></li>
|
||||
</ul>
|
||||
</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')}</Text>
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License Configuration')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
@ -128,177 +131,6 @@ export default function AdminPremiumSection() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Pro Features */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.proFeatures', 'Pro Features')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.premium.ssoAutoLogin', 'SSO Auto Login')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.premium.ssoAutoLogin.description', 'Automatically redirect to SSO login')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.proFeatures?.SSOAutoLogin || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
proFeatures: { ...settings.proFeatures, SSOAutoLogin: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.premium.customMetadata.autoUpdate', 'Auto Update Metadata')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.premium.customMetadata.autoUpdate.description', 'Automatically update PDF metadata')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.proFeatures?.CustomMetadata?.autoUpdateMetadata || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
proFeatures: {
|
||||
...settings.proFeatures,
|
||||
CustomMetadata: {
|
||||
...settings.proFeatures?.CustomMetadata,
|
||||
autoUpdateMetadata: e.target.checked
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.premium.customMetadata.author', 'Default Author')}
|
||||
description={t('admin.settings.premium.customMetadata.author.description', 'Default author for PDF metadata')}
|
||||
value={settings.proFeatures?.CustomMetadata?.author || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
proFeatures: {
|
||||
...settings.proFeatures,
|
||||
CustomMetadata: {
|
||||
...settings.proFeatures?.CustomMetadata,
|
||||
author: e.target.value
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.premium.customMetadata.creator', 'Default Creator')}
|
||||
description={t('admin.settings.premium.customMetadata.creator.description', 'Default creator for PDF metadata')}
|
||||
value={settings.proFeatures?.CustomMetadata?.creator || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
proFeatures: {
|
||||
...settings.proFeatures,
|
||||
CustomMetadata: {
|
||||
...settings.proFeatures?.CustomMetadata,
|
||||
creator: e.target.value
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="Stirling-PDF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.premium.customMetadata.producer', 'Default Producer')}
|
||||
description={t('admin.settings.premium.customMetadata.producer.description', 'Default producer for PDF metadata')}
|
||||
value={settings.proFeatures?.CustomMetadata?.producer || ''}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
proFeatures: {
|
||||
...settings.proFeatures,
|
||||
CustomMetadata: {
|
||||
...settings.proFeatures?.CustomMetadata,
|
||||
producer: e.target.value
|
||||
}
|
||||
}
|
||||
})}
|
||||
placeholder="Stirling-PDF"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Enterprise Features */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.enterpriseFeatures', 'Enterprise Features')}</Text>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.premium.audit.enabled', 'Enable Audit Logging')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.premium.audit.enabled.description', 'Track user actions and system events')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enterpriseFeatures?.audit?.enabled || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
enterpriseFeatures: {
|
||||
...settings.enterpriseFeatures,
|
||||
audit: {
|
||||
...settings.enterpriseFeatures?.audit,
|
||||
enabled: e.target.checked
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.premium.audit.level', 'Audit Level')}
|
||||
description={t('admin.settings.premium.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
|
||||
value={settings.enterpriseFeatures?.audit?.level || 2}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
enterpriseFeatures: {
|
||||
...settings.enterpriseFeatures,
|
||||
audit: {
|
||||
...settings.enterpriseFeatures?.audit,
|
||||
level: Number(value)
|
||||
}
|
||||
}
|
||||
})}
|
||||
min={0}
|
||||
max={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.premium.audit.retentionDays', 'Audit Retention (days)')}
|
||||
description={t('admin.settings.premium.audit.retentionDays.description', 'Number of days to retain audit logs')}
|
||||
value={settings.enterpriseFeatures?.audit?.retentionDays || 90}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
enterpriseFeatures: {
|
||||
...settings.enterpriseFeatures,
|
||||
audit: {
|
||||
...settings.enterpriseFeatures?.audit,
|
||||
retentionDays: Number(value)
|
||||
}
|
||||
}
|
||||
})}
|
||||
min={1}
|
||||
max={3650}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, PasswordInput, Alert } from '@mantine/core';
|
||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, PasswordInput, Alert, Badge, Accordion, Textarea } from '@mantine/core';
|
||||
import { alert } from '../../../toast';
|
||||
import LocalIcon from '../../LocalIcon';
|
||||
import RestartConfirmationModal from '../RestartConfirmationModal';
|
||||
@ -19,6 +19,24 @@ interface SecuritySettingsData {
|
||||
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() {
|
||||
@ -34,11 +52,38 @@ export default function AdminSecuritySection() {
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/section/security');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
}
|
||||
const securityResponse = await fetch('/api/v1/admin/settings/section/security');
|
||||
const securityData = securityResponse.ok ? await securityResponse.json() : {};
|
||||
|
||||
// Fetch premium settings for audit logging
|
||||
const premiumResponse = await fetch('/api/v1/admin/settings/section/premium');
|
||||
const premiumData = premiumResponse.ok ? await premiumResponse.json() : {};
|
||||
|
||||
// Fetch system settings for HTML URL security
|
||||
const systemResponse = await fetch('/api/v1/admin/settings/section/system');
|
||||
const systemData = systemResponse.ok ? await systemResponse.json() : {};
|
||||
|
||||
setSettings({
|
||||
...securityData,
|
||||
audit: premiumData.enterpriseFeatures?.audit || {
|
||||
enabled: false,
|
||||
level: 2,
|
||||
retentionDays: 90
|
||||
},
|
||||
html: systemData.html || {
|
||||
urlSecurity: {
|
||||
enabled: true,
|
||||
level: 'MEDIUM',
|
||||
allowedDomains: [],
|
||||
blockedDomains: [],
|
||||
internalTlds: ['.local', '.internal', '.corp', '.home'],
|
||||
blockPrivateNetworks: true,
|
||||
blockLocalhost: true,
|
||||
blockLinkLocal: true,
|
||||
blockCloudMetadata: true
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch security settings:', error);
|
||||
alert({
|
||||
@ -54,13 +99,42 @@ export default function AdminSecuritySection() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/section/security', {
|
||||
// Save security settings (excluding audit and html)
|
||||
const { audit, html, ...securitySettings } = settings;
|
||||
|
||||
const securityResponse = await fetch('/api/v1/admin/settings/section/security', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
body: JSON.stringify(securitySettings),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Save audit settings via delta endpoint
|
||||
const deltaSettings: Record<string, any> = {
|
||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||
'premium.enterpriseFeatures.audit.level': audit?.level,
|
||||
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
|
||||
};
|
||||
|
||||
// Save HTML URL security settings via delta endpoint
|
||||
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;
|
||||
}
|
||||
|
||||
const deltaResponse = await fetch('/api/v1/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: deltaSettings }),
|
||||
});
|
||||
|
||||
if (securityResponse.ok && deltaResponse.ok) {
|
||||
showRestartModal();
|
||||
} else {
|
||||
throw new Error('Failed to save');
|
||||
@ -246,6 +320,257 @@ export default function AdminSecuritySection() {
|
||||
</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', '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', '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>
|
||||
<Switch
|
||||
checked={settings.audit?.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.security.audit.level', 'Audit Level')}
|
||||
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={t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}
|
||||
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', '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', '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>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.enabled || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
label={t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}
|
||||
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={t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}
|
||||
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={t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}
|
||||
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={t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}
|
||||
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', '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>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost', '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>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLocalhost || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal', '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>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata', '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>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Save Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={saving} size="sm">
|
||||
|
||||
@ -18,9 +18,11 @@ export type NavKey =
|
||||
| 'adminSecurity'
|
||||
| 'adminConnections'
|
||||
| 'adminPrivacy'
|
||||
| 'adminDatabase'
|
||||
| 'adminAdvanced'
|
||||
| 'adminLegal'
|
||||
| 'adminPremium'
|
||||
| 'adminFeatures'
|
||||
| 'adminEndpoints';
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
|
||||
import { Group, useMantineColorScheme } from "@mantine/core";
|
||||
import { useSidebarContext } from "../contexts/SidebarContext";
|
||||
import { useDocumentMeta } from "../hooks/useDocumentMeta";
|
||||
import { useAppConfig } from "../hooks/useAppConfig";
|
||||
import { BASE_PATH, getBaseUrl } from "../constants/app";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user