settings updates

This commit is contained in:
Anthony Stirling 2025-10-21 17:56:07 +01:00
parent 82cf8cfde4
commit 634eb564d6
12 changed files with 1852 additions and 259 deletions

View File

@ -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());

View File

@ -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",

View File

@ -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',

View File

@ -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 */}

View File

@ -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 && (
<>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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')}

View File

@ -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&#10;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&#10;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&#10;.internal&#10;.corp&#10;.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">

View File

@ -18,9 +18,11 @@ export type NavKey =
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminDatabase'
| 'adminAdvanced'
| 'adminLegal'
| 'adminPremium'
| 'adminFeatures'
| 'adminEndpoints';

View File

@ -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';