settingsPage Init selfhost

This commit is contained in:
Anthony Stirling 2025-10-15 23:37:27 +01:00
parent f6a7b983a0
commit b3c1b4791c
14 changed files with 2637 additions and 4 deletions

View File

@ -1,11 +1,13 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import io.swagger.v3.oas.annotations.Hidden;
@ -46,4 +48,301 @@ public class SettingsController {
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
}
// ========== GENERAL SETTINGS ==========
@GetMapping("/admin/settings/general")
@Hidden
public ResponseEntity<Map<String, Object>> getGeneralSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui", applicationProperties.getUi());
settings.put("system", Map.of(
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
"showUpdateOnlyAdmin", applicationProperties.getSystem().getShowUpdateOnlyAdmin(),
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()
));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/general")
@Hidden
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings) throws IOException {
// Update UI settings
if (settings.containsKey("ui")) {
Map<String, String> ui = (Map<String, String>) settings.get("ui");
if (ui.containsKey("appName")) {
GeneralUtils.saveKeyToSettings("ui.appName", ui.get("appName"));
applicationProperties.getUi().setAppName(ui.get("appName"));
}
if (ui.containsKey("homeDescription")) {
GeneralUtils.saveKeyToSettings("ui.homeDescription", ui.get("homeDescription"));
applicationProperties.getUi().setHomeDescription(ui.get("homeDescription"));
}
if (ui.containsKey("appNameNavbar")) {
GeneralUtils.saveKeyToSettings("ui.appNameNavbar", ui.get("appNameNavbar"));
applicationProperties.getUi().setAppNameNavbar(ui.get("appNameNavbar"));
}
}
// Update System settings
if (settings.containsKey("system")) {
Map<String, Object> system = (Map<String, Object>) settings.get("system");
if (system.containsKey("defaultLocale")) {
GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale"));
applicationProperties.getSystem().setDefaultLocale((String) system.get("defaultLocale"));
}
if (system.containsKey("showUpdate")) {
GeneralUtils.saveKeyToSettings("system.showUpdate", system.get("showUpdate"));
applicationProperties.getSystem().setShowUpdate((Boolean) system.get("showUpdate"));
}
if (system.containsKey("showUpdateOnlyAdmin")) {
GeneralUtils.saveKeyToSettings("system.showUpdateOnlyAdmin", system.get("showUpdateOnlyAdmin"));
applicationProperties.getSystem().setShowUpdateOnlyAdmin((Boolean) system.get("showUpdateOnlyAdmin"));
}
if (system.containsKey("fileUploadLimit")) {
GeneralUtils.saveKeyToSettings("system.fileUploadLimit", system.get("fileUploadLimit"));
applicationProperties.getSystem().setFileUploadLimit((String) system.get("fileUploadLimit"));
}
}
return ResponseEntity.ok("General settings updated. Restart required for changes to take effect.");
}
// ========== SECURITY SETTINGS ==========
@GetMapping("/admin/settings/security")
@Hidden
public ResponseEntity<Map<String, Object>> getSecuritySettings() {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("csrfDisabled", security.getCsrfDisabled());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
settings.put("initialLogin", Map.of(
"username", security.getInitialLogin().getUsername() != null ? security.getInitialLogin().getUsername() : ""
));
// JWT settings
ApplicationProperties.Security.Jwt jwt = security.getJwt();
settings.put("jwt", Map.of(
"enableKeystore", jwt.isEnableKeystore(),
"enableKeyRotation", jwt.isEnableKeyRotation(),
"enableKeyCleanup", jwt.isEnableKeyCleanup(),
"keyRetentionDays", jwt.getKeyRetentionDays(),
"secureCookie", jwt.isSecureCookie()
));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/security")
@Hidden
public ResponseEntity<String> updateSecuritySettings(@RequestBody Map<String, Object> settings) throws IOException {
if (settings.containsKey("enableLogin")) {
GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin"));
applicationProperties.getSecurity().setEnableLogin((Boolean) settings.get("enableLogin"));
}
if (settings.containsKey("csrfDisabled")) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
applicationProperties.getSecurity().setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
}
if (settings.containsKey("loginMethod")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties.getSecurity().setLoginMethod((String) settings.get("loginMethod"));
}
if (settings.containsKey("loginAttemptCount")) {
GeneralUtils.saveKeyToSettings("security.loginAttemptCount", settings.get("loginAttemptCount"));
applicationProperties.getSecurity().setLoginAttemptCount((Integer) settings.get("loginAttemptCount"));
}
if (settings.containsKey("loginResetTimeMinutes")) {
GeneralUtils.saveKeyToSettings("security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes"));
applicationProperties.getSecurity().setLoginResetTimeMinutes(((Number) settings.get("loginResetTimeMinutes")).longValue());
}
// JWT settings
if (settings.containsKey("jwt")) {
Map<String, Object> jwt = (Map<String, Object>) settings.get("jwt");
if (jwt.containsKey("secureCookie")) {
GeneralUtils.saveKeyToSettings("security.jwt.secureCookie", jwt.get("secureCookie"));
applicationProperties.getSecurity().getJwt().setSecureCookie((Boolean) jwt.get("secureCookie"));
}
if (jwt.containsKey("keyRetentionDays")) {
GeneralUtils.saveKeyToSettings("security.jwt.keyRetentionDays", jwt.get("keyRetentionDays"));
applicationProperties.getSecurity().getJwt().setKeyRetentionDays((Integer) jwt.get("keyRetentionDays"));
}
}
return ResponseEntity.ok("Security settings updated. Restart required for changes to take effect.");
}
// ========== CONNECTIONS SETTINGS (OAuth/SAML) ==========
@GetMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<Map<String, Object>> getConnectionsSettings() {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
// OAuth2 settings
ApplicationProperties.Security.OAUTH2 oauth2 = security.getOauth2();
settings.put("oauth2", Map.of(
"enabled", oauth2.getEnabled(),
"issuer", oauth2.getIssuer() != null ? oauth2.getIssuer() : "",
"clientId", oauth2.getClientId() != null ? oauth2.getClientId() : "",
"provider", oauth2.getProvider() != null ? oauth2.getProvider() : "",
"autoCreateUser", oauth2.getAutoCreateUser(),
"blockRegistration", oauth2.getBlockRegistration(),
"useAsUsername", oauth2.getUseAsUsername() != null ? oauth2.getUseAsUsername() : ""
));
// SAML2 settings
ApplicationProperties.Security.SAML2 saml2 = security.getSaml2();
settings.put("saml2", Map.of(
"enabled", saml2.getEnabled(),
"provider", saml2.getProvider() != null ? saml2.getProvider() : "",
"autoCreateUser", saml2.getAutoCreateUser(),
"blockRegistration", saml2.getBlockRegistration(),
"registrationId", saml2.getRegistrationId()
));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<String> updateConnectionsSettings(@RequestBody Map<String, Object> settings) throws IOException {
// OAuth2 settings
if (settings.containsKey("oauth2")) {
Map<String, Object> oauth2 = (Map<String, Object>) settings.get("oauth2");
if (oauth2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled"));
applicationProperties.getSecurity().getOauth2().setEnabled((Boolean) oauth2.get("enabled"));
}
if (oauth2.containsKey("issuer")) {
GeneralUtils.saveKeyToSettings("security.oauth2.issuer", oauth2.get("issuer"));
applicationProperties.getSecurity().getOauth2().setIssuer((String) oauth2.get("issuer"));
}
if (oauth2.containsKey("clientId")) {
GeneralUtils.saveKeyToSettings("security.oauth2.clientId", oauth2.get("clientId"));
applicationProperties.getSecurity().getOauth2().setClientId((String) oauth2.get("clientId"));
}
if (oauth2.containsKey("clientSecret")) {
GeneralUtils.saveKeyToSettings("security.oauth2.clientSecret", oauth2.get("clientSecret"));
applicationProperties.getSecurity().getOauth2().setClientSecret((String) oauth2.get("clientSecret"));
}
if (oauth2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.oauth2.provider", oauth2.get("provider"));
applicationProperties.getSecurity().getOauth2().setProvider((String) oauth2.get("provider"));
}
if (oauth2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings("security.oauth2.autoCreateUser", oauth2.get("autoCreateUser"));
applicationProperties.getSecurity().getOauth2().setAutoCreateUser((Boolean) oauth2.get("autoCreateUser"));
}
if (oauth2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings("security.oauth2.blockRegistration", oauth2.get("blockRegistration"));
applicationProperties.getSecurity().getOauth2().setBlockRegistration((Boolean) oauth2.get("blockRegistration"));
}
if (oauth2.containsKey("useAsUsername")) {
GeneralUtils.saveKeyToSettings("security.oauth2.useAsUsername", oauth2.get("useAsUsername"));
applicationProperties.getSecurity().getOauth2().setUseAsUsername((String) oauth2.get("useAsUsername"));
}
}
// SAML2 settings
if (settings.containsKey("saml2")) {
Map<String, Object> saml2 = (Map<String, Object>) settings.get("saml2");
if (saml2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled"));
applicationProperties.getSecurity().getSaml2().setEnabled((Boolean) saml2.get("enabled"));
}
if (saml2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.saml2.provider", saml2.get("provider"));
applicationProperties.getSecurity().getSaml2().setProvider((String) saml2.get("provider"));
}
if (saml2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings("security.saml2.autoCreateUser", saml2.get("autoCreateUser"));
applicationProperties.getSecurity().getSaml2().setAutoCreateUser((Boolean) saml2.get("autoCreateUser"));
}
if (saml2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings("security.saml2.blockRegistration", saml2.get("blockRegistration"));
applicationProperties.getSecurity().getSaml2().setBlockRegistration((Boolean) saml2.get("blockRegistration"));
}
}
return ResponseEntity.ok("Connection settings updated. Restart required for changes to take effect.");
}
// ========== PRIVACY SETTINGS ==========
@GetMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<Map<String, Object>> getPrivacySettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
settings.put("googleVisibility", applicationProperties.getSystem().getGooglevisibility());
settings.put("metricsEnabled", applicationProperties.getMetrics().getEnabled());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<String> updatePrivacySettings(@RequestBody Map<String, Object> settings) throws IOException {
if (settings.containsKey("enableAnalytics")) {
GeneralUtils.saveKeyToSettings("system.enableAnalytics", settings.get("enableAnalytics"));
applicationProperties.getSystem().setEnableAnalytics((Boolean) settings.get("enableAnalytics"));
}
if (settings.containsKey("googleVisibility")) {
GeneralUtils.saveKeyToSettings("system.googlevisibility", settings.get("googleVisibility"));
applicationProperties.getSystem().setGooglevisibility((Boolean) settings.get("googleVisibility"));
}
if (settings.containsKey("metricsEnabled")) {
GeneralUtils.saveKeyToSettings("metrics.enabled", settings.get("metricsEnabled"));
applicationProperties.getMetrics().setEnabled((Boolean) settings.get("metricsEnabled"));
}
return ResponseEntity.ok("Privacy settings updated. Restart required for changes to take effect.");
}
// ========== ADVANCED SETTINGS ==========
@GetMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<Map<String, Object>> getAdvancedSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("endpoints", applicationProperties.getEndpoints());
settings.put("enableAlphaFunctionality", applicationProperties.getSystem().getEnableAlphaFunctionality());
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
settings.put("enableUrlToPDF", applicationProperties.getSystem().getEnableUrlToPDF());
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
settings.put("tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings) throws IOException {
if (settings.containsKey("enableAlphaFunctionality")) {
GeneralUtils.saveKeyToSettings("system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality"));
applicationProperties.getSystem().setEnableAlphaFunctionality((Boolean) settings.get("enableAlphaFunctionality"));
}
if (settings.containsKey("maxDPI")) {
GeneralUtils.saveKeyToSettings("system.maxDPI", settings.get("maxDPI"));
applicationProperties.getSystem().setMaxDPI((Integer) settings.get("maxDPI"));
}
if (settings.containsKey("enableUrlToPDF")) {
GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF"));
applicationProperties.getSystem().setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF"));
}
return ResponseEntity.ok("Advanced settings updated. Restart required for changes to take effect.");
}
}

View File

@ -3207,8 +3207,218 @@
"activity": "Activity",
"account": "Account",
"config": "Config",
"adminSettings": "Admin Settings",
"allTools": "All Tools"
},
"admin": {
"error": "Error",
"success": "Success",
"status": {
"active": "Active",
"inactive": "Inactive"
},
"settings": {
"title": "Admin Settings",
"workspace": "Workspace",
"fetchError": "Failed to load settings",
"saveError": "Failed to save settings",
"saved": "Settings saved successfully",
"save": "Save Changes",
"restartRequired": "Settings changes require a server restart to take effect.",
"general": {
"title": "General",
"description": "Configure general application settings including branding and default behaviour.",
"ui": "User Interface",
"system": "System",
"appName": "Application Name",
"appName.description": "The name displayed in the browser tab and home page",
"appNameNavbar": "Navbar Brand",
"appNameNavbar.description": "The name displayed in the navigation bar",
"homeDescription": "Home Description",
"homeDescription.description": "The description text shown on the home page",
"defaultLocale": "Default Locale",
"defaultLocale.description": "The default language for new users (e.g., en_US, es_ES)",
"fileUploadLimit": "File Upload Limit",
"fileUploadLimit.description": "Maximum file upload size (e.g., 100MB, 1GB)",
"showUpdate": "Show Update Notifications",
"showUpdate.description": "Display notifications when a new version is available",
"showUpdateOnlyAdmin": "Show Updates to Admins Only",
"showUpdateOnlyAdmin.description": "Restrict update notifications to admin users only",
"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)"
},
"security": {
"title": "Security",
"description": "Configure authentication, login behaviour, and security policies.",
"authentication": "Authentication",
"enableLogin": "Enable Login",
"enableLogin.description": "Require users to log in before accessing the application",
"loginMethod": "Login Method",
"loginMethod.description": "The authentication method to use for user login",
"loginMethod.all": "All Methods",
"loginMethod.normal": "Username/Password Only",
"loginMethod.oauth2": "OAuth2 Only",
"loginMethod.saml2": "SAML2 Only",
"loginAttemptCount": "Login Attempt Limit",
"loginAttemptCount.description": "Maximum number of failed login attempts before account lockout",
"loginResetTimeMinutes": "Login Reset Time (minutes)",
"loginResetTimeMinutes.description": "Time before failed login attempts are reset",
"csrfDisabled": "Disable CSRF Protection",
"csrfDisabled.description": "Disable Cross-Site Request Forgery protection (not recommended)",
"initialLogin": "Initial Login",
"initialLogin.username": "Initial Username",
"initialLogin.username.description": "The username for the initial admin account",
"initialLogin.password": "Initial Password",
"initialLogin.password.description": "The password for the initial admin account",
"jwt": "JWT Configuration",
"jwt.secureCookie": "Secure Cookie",
"jwt.secureCookie.description": "Require HTTPS for JWT cookies (recommended for production)",
"jwt.keyRetentionDays": "Key Retention Days",
"jwt.keyRetentionDays.description": "Number of days to retain old JWT keys for verification",
"jwt.persistence": "Enable Key Persistence",
"jwt.persistence.description": "Store JWT keys persistently to survive server restarts",
"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"
},
"connections": {
"title": "Connections",
"description": "Configure external authentication providers like OAuth2 and SAML.",
"oauth2": "OAuth2",
"oauth2.enabled": "Enable OAuth2",
"oauth2.enabled.description": "Allow users to authenticate using OAuth2 providers",
"oauth2.provider": "Provider",
"oauth2.provider.description": "The OAuth2 provider to use for authentication",
"oauth2.issuer": "Issuer URL",
"oauth2.issuer.description": "The OAuth2 provider issuer URL",
"oauth2.clientId": "Client ID",
"oauth2.clientId.description": "The OAuth2 client ID from your provider",
"oauth2.clientSecret": "Client Secret",
"oauth2.clientSecret.description": "The OAuth2 client secret from your provider",
"oauth2.useAsUsername": "Use as Username",
"oauth2.useAsUsername.description": "The OAuth2 claim to use as the username (e.g., email, sub)",
"oauth2.autoCreateUser": "Auto Create Users",
"oauth2.autoCreateUser.description": "Automatically create user accounts on first OAuth2 login",
"oauth2.blockRegistration": "Block Registration",
"oauth2.blockRegistration.description": "Prevent new user registration via OAuth2",
"oauth2.scopes": "OAuth2 Scopes",
"oauth2.scopes.description": "Comma-separated list of OAuth2 scopes to request (e.g., openid, profile, email)",
"saml2": "SAML2",
"saml2.enabled": "Enable SAML2",
"saml2.enabled.description": "Allow users to authenticate using SAML2 providers",
"saml2.provider": "Provider",
"saml2.provider.description": "The SAML2 provider name",
"saml2.registrationId": "Registration ID",
"saml2.registrationId.description": "The SAML2 registration identifier",
"saml2.autoCreateUser": "Auto Create Users",
"saml2.autoCreateUser.description": "Automatically create user accounts on first SAML2 login",
"saml2.blockRegistration": "Block Registration",
"saml2.blockRegistration.description": "Prevent new user registration via SAML2"
},
"privacy": {
"title": "Privacy",
"description": "Configure privacy and data collection settings.",
"analytics": "Analytics & Tracking",
"enableAnalytics": "Enable Analytics",
"enableAnalytics.description": "Collect anonymous usage analytics to help improve the application",
"metricsEnabled": "Enable Metrics",
"metricsEnabled.description": "Enable collection of performance and usage metrics",
"searchEngine": "Search Engine Visibility",
"googleVisibility": "Google Visibility",
"googleVisibility.description": "Allow search engines to index this application"
},
"advanced": {
"title": "Advanced",
"description": "Configure advanced features and experimental functionality.",
"features": "Feature Flags",
"processing": "Processing",
"endpoints": "Endpoints",
"endpoints.manage": "Manage API Endpoints",
"endpoints.description": "Endpoint management is configured via YAML. See documentation for details on enabling/disabling specific endpoints.",
"enableAlphaFunctionality": "Enable Alpha Features",
"enableAlphaFunctionality.description": "Enable experimental and alpha-stage features (may be unstable)",
"enableUrlToPDF": "Enable URL to PDF",
"enableUrlToPDF.description": "Allow conversion of web pages to PDF documents",
"maxDPI": "Maximum DPI",
"maxDPI.description": "Maximum DPI for image processing (0 = unlimited)",
"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"
},
"mail": {
"title": "Mail Server",
"description": "Configure SMTP settings for sending email notifications.",
"smtp": "SMTP Configuration",
"enabled": "Enable Mail",
"enabled.description": "Enable email notifications and SMTP functionality",
"host": "SMTP Host",
"host.description": "The hostname or IP address of your SMTP server",
"port": "SMTP Port",
"port.description": "The port number for SMTP connection (typically 25, 465, or 587)",
"username": "SMTP Username",
"username.description": "Username for SMTP authentication",
"password": "SMTP Password",
"password.description": "Password for SMTP authentication",
"from": "From Address",
"from.description": "The email address to use as the sender"
},
"legal": {
"title": "Legal Documents",
"description": "Configure links to legal documents and policies.",
"termsAndConditions": "Terms and Conditions",
"termsAndConditions.description": "URL or filename to terms and conditions",
"privacyPolicy": "Privacy Policy",
"privacyPolicy.description": "URL or filename to privacy policy",
"accessibilityStatement": "Accessibility Statement",
"accessibilityStatement.description": "URL or filename to accessibility statement",
"cookiePolicy": "Cookie Policy",
"cookiePolicy.description": "URL or filename to cookie policy",
"impressum": "Impressum",
"impressum.description": "URL or filename to impressum (required in some jurisdictions)"
},
"premium": {
"title": "Premium & Enterprise",
"description": "Configure premium and enterprise features.",
"license": "License",
"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"
},
"endpoints": {
"title": "API Endpoints",
"description": "Control which API endpoints and endpoint groups are available.",
"management": "Endpoint Management",
"toRemove": "Disabled Endpoints",
"toRemove.description": "Select individual endpoints to disable",
"groupsToRemove": "Disabled Endpoint Groups",
"groupsToRemove.description": "Select endpoint groups to disable",
"note": "Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect."
}
}
},
"fileUpload": {
"selectFile": "Select a file",
"selectFiles": "Select files",

View File

@ -44,13 +44,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
console.log('Logout placeholder for SaaS compatibility');
};
// TODO: Replace with actual role check from JWT/auth context
const isAdmin = true; // Emulated admin role for now
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
handleLogout,
isAdmin
),
[]
[isAdmin]
);
const activeLabel = useMemo(() => {

View File

@ -2,6 +2,15 @@ import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
import AdminGeneralSection from './configSections/AdminGeneralSection';
import AdminSecuritySection from './configSections/AdminSecuritySection';
import AdminConnectionsSection from './configSections/AdminConnectionsSection';
import AdminPrivacySection from './configSections/AdminPrivacySection';
import AdminAdvancedSection from './configSections/AdminAdvancedSection';
import AdminMailSection from './configSections/AdminMailSection';
import AdminLegalSection from './configSections/AdminLegalSection';
import AdminPremiumSection from './configSections/AdminPremiumSection';
import AdminEndpointsSection from './configSections/AdminEndpointsSection';
export interface ConfigNavItem {
key: NavKey;
@ -27,7 +36,8 @@ export interface ConfigColors {
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void
onLogoutClick: () => void,
isAdmin: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@ -60,5 +70,68 @@ export const createConfigNavSections = (
},
];
// Add Admin Settings section if user is admin
if (isAdmin) {
sections.push({
title: 'Admin Settings',
items: [
{
key: 'adminGeneral',
label: 'General',
icon: 'settings-rounded',
component: <AdminGeneralSection />
},
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />
},
{
key: 'adminMail',
label: 'Mail',
icon: 'mail-rounded',
component: <AdminMailSection />
},
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />
},
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
},
],
});
}
return sections;
};

View File

@ -0,0 +1,202 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput } from '@mantine/core';
import { alert } from '../../../toast';
interface AdvancedSettingsData {
enableAlphaFunctionality?: boolean;
maxDPI?: number;
enableUrlToPDF?: boolean;
tessdataDir?: string;
disableSanitize?: boolean;
}
export default function AdminAdvancedSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<AdvancedSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
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
});
}
} catch (error) {
console.error('Failed to fetch advanced 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 {
// Use delta update endpoint with dot notation
const deltaSettings = {
'system.enableAlphaFunctionality': settings.enableAlphaFunctionality,
'system.maxDPI': settings.maxDPI,
'system.enableUrlToPDF': settings.enableUrlToPDF,
'system.tessdataDir': settings.tessdataDir,
'system.disableSanitize': settings.disableSanitize
};
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.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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.advanced.title', 'Advanced')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.advanced.description', 'Configure advanced features and experimental functionality.')}
</Text>
</div>
{/* Feature Flags */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.features', 'Feature Flags')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality', 'Enable Alpha Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')}
</Text>
</div>
<Switch
checked={settings.enableAlphaFunctionality || false}
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF', 'Enable URL to PDF')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')}
</Text>
</div>
<Switch
checked={settings.enableUrlToPDF || false}
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize', 'Disable HTML Sanitization')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')}
</Text>
</div>
<Switch
checked={settings.disableSanitize || false}
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
/>
</div>
</Stack>
</Paper>
{/* Processing Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.processing', 'Processing')}</Text>
<div>
<NumberInput
label={t('admin.settings.advanced.maxDPI', 'Maximum DPI')}
description={t('admin.settings.advanced.maxDPI.description', 'Maximum DPI for image processing (0 = unlimited)')}
value={settings.maxDPI || 0}
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
min={0}
max={3000}
/>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}
description={t('admin.settings.advanced.tessdataDir.description', 'Path to the directory containing Tessdata files for OCR')}
value={settings.tessdataDir || ''}
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
placeholder="/usr/share/tessdata"
/>
</div>
</Stack>
</Paper>
{/* Endpoints Info */}
<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.')}
</Text>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@ -0,0 +1,341 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, Badge, PasswordInput } from '@mantine/core';
import { alert } from '../../../toast';
import LocalIcon from '../../LocalIcon';
interface OAuth2Settings {
enabled?: boolean;
issuer?: string;
clientId?: string;
clientSecret?: string;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
useAsUsername?: string;
scopes?: string;
}
interface SAML2Settings {
enabled?: boolean;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
registrationId?: string;
}
interface ConnectionsSettingsData {
oauth2?: OAuth2Settings;
saml2?: SAML2Settings;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<ConnectionsSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
// OAuth2 and SAML2 are nested under security section
const response = await fetch('/api/v1/admin/settings/section/security');
if (response.ok) {
const data = await response.json();
// Extract oauth2 and saml2 from security section
setSettings({
oauth2: data.oauth2 || {},
saml2: data.saml2 || {}
});
}
} catch (error) {
console.error('Failed to fetch connections 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 {
// Use delta update endpoint with dot notation for nested oauth2/saml2 settings
const deltaSettings: Record<string, any> = {};
// Convert oauth2 settings to dot notation
if (settings.oauth2) {
Object.keys(settings.oauth2).forEach(key => {
deltaSettings[`security.oauth2.${key}`] = (settings.oauth2 as any)[key];
});
}
// Convert saml2 settings to dot notation
if (settings.saml2) {
Object.keys(settings.saml2).forEach(key => {
deltaSettings[`security.saml2.${key}`] = (settings.saml2 as any)[key];
});
}
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.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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>
);
}
const getProviderIcon = (provider?: string) => {
switch (provider?.toLowerCase()) {
case 'google':
return <LocalIcon icon="google-rounded" width="1rem" height="1rem" />;
case 'github':
return <LocalIcon icon="github-rounded" width="1rem" height="1rem" />;
case 'keycloak':
return <LocalIcon icon="key-rounded" width="1rem" height="1rem" />;
default:
return <LocalIcon icon="cloud-rounded" width="1rem" height="1rem" />;
}
};
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.connections.title', 'Connections')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.connections.description', 'Configure external authentication providers like OAuth2 and SAML.')}
</Text>
</div>
{/* OAuth2 Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<LocalIcon icon="cloud-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">{t('admin.settings.connections.oauth2', 'OAuth2')}</Text>
</Group>
<Badge color={settings.oauth2?.enabled ? 'green' : 'gray'} size="sm">
{settings.oauth2?.enabled ? t('admin.status.active', 'Active') : t('admin.status.inactive', 'Inactive')}
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.oauth2.enabled', 'Enable OAuth2')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.oauth2.enabled.description', 'Allow users to authenticate using OAuth2 providers')}
</Text>
</div>
<Switch
checked={settings.oauth2?.enabled || false}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, enabled: e.target.checked } })}
/>
</div>
<div>
<Select
label={t('admin.settings.connections.oauth2.provider', 'Provider')}
description={t('admin.settings.connections.oauth2.provider.description', 'The OAuth2 provider to use for authentication')}
value={settings.oauth2?.provider || ''}
onChange={(value) => setSettings({ ...settings, oauth2: { ...settings.oauth2, provider: value || '' } })}
data={[
{ value: 'google', label: 'Google' },
{ value: 'github', label: 'GitHub' },
{ value: 'keycloak', label: 'Keycloak' },
]}
leftSection={getProviderIcon(settings.oauth2?.provider)}
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.issuer', 'Issuer URL')}
description={t('admin.settings.connections.oauth2.issuer.description', 'The OAuth2 provider issuer URL')}
value={settings.oauth2?.issuer || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, issuer: e.target.value } })}
placeholder="https://accounts.google.com"
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.clientId', 'Client ID')}
description={t('admin.settings.connections.oauth2.clientId.description', 'The OAuth2 client ID from your provider')}
value={settings.oauth2?.clientId || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, clientId: e.target.value } })}
/>
</div>
<div>
<PasswordInput
label={t('admin.settings.connections.oauth2.clientSecret', 'Client Secret')}
description={t('admin.settings.connections.oauth2.clientSecret.description', 'The OAuth2 client secret from your provider')}
value={settings.oauth2?.clientSecret || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, clientSecret: e.target.value } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.oauth2.autoCreateUser', 'Auto Create Users')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.oauth2.autoCreateUser.description', 'Automatically create user accounts on first OAuth2 login')}
</Text>
</div>
<Switch
checked={settings.oauth2?.autoCreateUser || false}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, autoCreateUser: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.oauth2.blockRegistration', 'Block Registration')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.oauth2.blockRegistration.description', 'Prevent new user registration via OAuth2')}
</Text>
</div>
<Switch
checked={settings.oauth2?.blockRegistration || false}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, blockRegistration: e.target.checked } })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.useAsUsername', 'Use as Username')}
description={t('admin.settings.connections.oauth2.useAsUsername.description', 'The OAuth2 claim to use as the username (e.g., email, sub)')}
value={settings.oauth2?.useAsUsername || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, useAsUsername: e.target.value } })}
placeholder="email"
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.scopes', 'Scopes')}
description={t('admin.settings.connections.oauth2.scopes.description', 'OAuth2 scopes (comma-separated, e.g., openid, profile, email)')}
value={settings.oauth2?.scopes || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, scopes: e.target.value } })}
placeholder="openid, profile, email"
/>
</div>
</Stack>
</Paper>
{/* SAML2 Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<LocalIcon icon="key-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">{t('admin.settings.connections.saml2', 'SAML2')}</Text>
</Group>
<Badge color={settings.saml2?.enabled ? 'green' : 'gray'} size="sm">
{settings.saml2?.enabled ? t('admin.status.active', 'Active') : t('admin.status.inactive', 'Inactive')}
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.saml2.enabled', 'Enable SAML2')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.saml2.enabled.description', 'Allow users to authenticate using SAML2 providers')}
</Text>
</div>
<Switch
checked={settings.saml2?.enabled || false}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, enabled: e.target.checked } })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.saml2.provider', 'Provider')}
description={t('admin.settings.connections.saml2.provider.description', 'The SAML2 provider name')}
value={settings.saml2?.provider || ''}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, provider: e.target.value } })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.saml2.registrationId', 'Registration ID')}
description={t('admin.settings.connections.saml2.registrationId.description', 'The SAML2 registration identifier')}
value={settings.saml2?.registrationId || ''}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, registrationId: e.target.value } })}
placeholder="stirling"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.saml2.autoCreateUser', 'Auto Create Users')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.saml2.autoCreateUser.description', 'Automatically create user accounts on first SAML2 login')}
</Text>
</div>
<Switch
checked={settings.saml2?.autoCreateUser || false}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, autoCreateUser: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.saml2.blockRegistration', 'Block Registration')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.saml2.blockRegistration.description', 'Prevent new user registration via SAML2')}
</Text>
</div>
<Switch
checked={settings.saml2?.blockRegistration || false}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, blockRegistration: 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>
</Stack>
);
}

View File

@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core';
import { alert } from '../../../toast';
interface EndpointsSettingsData {
toRemove?: string[];
groupsToRemove?: string[];
}
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<EndpointsSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/endpoints');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch endpoints 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 response = await fetch('/api/v1/admin/settings/section/endpoints', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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>
);
}
// Common endpoint examples
const commonEndpoints = [
'img-to-pdf',
'pdf-to-img',
'merge-pdfs',
'split-pdf',
'rotate-pdf',
'compress-pdf',
'extract-images',
'extract-image-scans',
'add-watermark',
'remove-watermark',
'add-password',
'remove-password',
'change-permissions',
'ocr-pdf',
'pdf-to-pdfa',
'html-to-pdf',
'url-to-pdf',
'markdown-to-pdf',
'get-info-on-pdf',
'extract-pdf-metadata',
'pdf-to-single-page',
'crop',
'auto-split-pdf',
'sanitize-pdf',
'add-page-numbers',
'auto-rename',
'scale-pages',
'repair',
'flatten',
'remove-blanks',
'compare-pdfs'
];
// Common endpoint groups
const commonGroups = [
'Conversion',
'Security',
'Other',
'Organize',
'LibreOffice',
'CLI',
'Python',
'OpenCV'
];
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.endpoints.description', 'Control which API endpoints and endpoint groups are available.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.endpoints.management', 'Endpoint Management')}</Text>
<div>
<MultiSelect
label={t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
value={settings.toRemove || []}
onChange={(value) => setSettings({ ...settings, toRemove: value })}
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<MultiSelect
label={t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
value={settings.groupsToRemove || []}
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
data={commonGroups.map(group => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<Paper bg="var(--mantine-color-blue-light)" p="sm" radius="sm">
<Text size="xs" c="dimmed">
{t('admin.settings.endpoints.note', 'Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect.')}
</Text>
</Paper>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@ -0,0 +1,250 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core';
import { alert } from '../../../toast';
interface GeneralSettingsData {
ui: {
appName?: string;
homeDescription?: string;
appNameNavbar?: string;
languages?: string[];
};
system: {
defaultLocale?: string;
showUpdate?: boolean;
showUpdateOnlyAdmin?: boolean;
customHTMLFiles?: boolean;
fileUploadLimit?: string;
};
}
export default function AdminGeneralSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<GeneralSettingsData>({
ui: {},
system: {},
});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
// Fetch both ui and system sections from proprietary admin API
const [uiResponse, systemResponse] = await Promise.all([
fetch('/api/v1/admin/settings/section/ui'),
fetch('/api/v1/admin/settings/section/system')
]);
if (uiResponse.ok && systemResponse.ok) {
const ui = await uiResponse.json();
const system = await systemResponse.json();
setSettings({ ui, system });
}
} catch (error) {
console.error('Failed to fetch general 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 both ui and system sections separately using proprietary admin API
const [uiResponse, systemResponse] = await Promise.all([
fetch('/api/v1/admin/settings/section/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings.ui),
}),
fetch('/api/v1/admin/settings/section/system', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings.system),
})
]);
if (uiResponse.ok && systemResponse.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.general.description', 'Configure general application settings including branding and default behaviour.')}
</Text>
</div>
{/* UI Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.ui', 'User Interface')}</Text>
<div>
<TextInput
label={t('admin.settings.general.appName', 'Application Name')}
description={t('admin.settings.general.appName.description', 'The name displayed in the browser tab and home page')}
value={settings.ui.appName || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appName: e.target.value } })}
placeholder="Stirling PDF"
/>
</div>
<div>
<TextInput
label={t('admin.settings.general.appNameNavbar', 'Navbar Brand')}
description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')}
value={settings.ui.appNameNavbar || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })}
placeholder="Stirling PDF"
/>
</div>
<div>
<TextInput
label={t('admin.settings.general.homeDescription', 'Home Description')}
description={t('admin.settings.general.homeDescription.description', 'The description text shown on the home page')}
value={settings.ui.homeDescription || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, homeDescription: e.target.value } })}
placeholder="Your locally hosted one-stop-shop for all your PDF needs"
/>
</div>
<div>
<MultiSelect
label={t('admin.settings.general.languages', 'Available Languages')}
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
value={settings.ui.languages || []}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
data={[
{ value: 'de_DE', label: 'Deutsch' },
{ value: 'es_ES', label: 'Español' },
{ value: 'fr_FR', label: 'Français' },
{ value: 'it_IT', label: 'Italiano' },
{ value: 'pl_PL', label: 'Polski' },
{ value: 'pt_BR', label: 'Português (Brasil)' },
{ value: 'ru_RU', label: 'Русский' },
{ value: 'zh_CN', label: '简体中文' },
{ value: 'ja_JP', label: '日本語' },
{ value: 'ko_KR', label: '한국어' },
]}
searchable
clearable
placeholder="Select languages"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
</Stack>
</Paper>
{/* System Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.system', 'System')}</Text>
<div>
<TextInput
label={t('admin.settings.general.defaultLocale', 'Default Locale')}
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
value={settings.system.defaultLocale || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
placeholder="en_US"
/>
</div>
<div>
<TextInput
label={t('admin.settings.general.fileUploadLimit', 'File Upload Limit')}
description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')}
value={settings.system.fileUploadLimit || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })}
placeholder="100MB"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate', 'Show Update Notifications')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdate.description', 'Display notifications when a new version is available')}
</Text>
</div>
<Switch
checked={settings.system.showUpdate || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin', 'Show Updates to Admins Only')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdateOnlyAdmin.description', 'Restrict update notifications to admin users only')}
</Text>
</div>
<Switch
checked={settings.system.showUpdateOnlyAdmin || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles', 'Custom HTML Files')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customHTMLFiles.description', 'Allow serving custom HTML files from the customFiles directory')}
</Text>
</div>
<Switch
checked={settings.system.customHTMLFiles || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: 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>
</Stack>
);
}

View File

@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '../../../toast';
interface LegalSettingsData {
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
export default function AdminLegalSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<LegalSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/legal');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch legal 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 response = await fetch('/api/v1/admin/settings/section/legal', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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.legal.title', 'Legal Documents')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.legal.description', 'Configure links to legal documents and policies.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}
description={t('admin.settings.legal.termsAndConditions.description', 'URL or filename to terms and conditions')}
value={settings.termsAndConditions || ''}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
/>
</div>
<div>
<TextInput
label={t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}
description={t('admin.settings.legal.privacyPolicy.description', 'URL or filename to privacy policy')}
value={settings.privacyPolicy || ''}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
/>
</div>
<div>
<TextInput
label={t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}
description={t('admin.settings.legal.accessibilityStatement.description', 'URL or filename to accessibility statement')}
value={settings.accessibilityStatement || ''}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
/>
</div>
<div>
<TextInput
label={t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}
description={t('admin.settings.legal.cookiePolicy.description', 'URL or filename to cookie policy')}
value={settings.cookiePolicy || ''}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
/>
</div>
<div>
<TextInput
label={t('admin.settings.legal.impressum', 'Impressum')}
description={t('admin.settings.legal.impressum.description', 'URL or filename to impressum (required in some jurisdictions)')}
value={settings.impressum || ''}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput } from '@mantine/core';
import { alert } from '../../../toast';
interface MailSettingsData {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
export default function AdminMailSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<MailSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/mail');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch mail 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 response = await fetch('/api/v1/admin/settings/section/mail', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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.mail.title', 'Mail Configuration')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enabled', 'Enable Mail')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')}
</Text>
</div>
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.mail.host', 'SMTP Host')}
description={t('admin.settings.mail.host.description', 'SMTP server hostname')}
value={settings.host || ''}
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
placeholder="smtp.example.com"
/>
</div>
<div>
<NumberInput
label={t('admin.settings.mail.port', 'SMTP Port')}
description={t('admin.settings.mail.port.description', 'SMTP server port (typically 587 for TLS, 465 for SSL)')}
value={settings.port || 587}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
/>
</div>
<div>
<TextInput
label={t('admin.settings.mail.username', 'SMTP Username')}
description={t('admin.settings.mail.username.description', 'SMTP authentication username')}
value={settings.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
/>
</div>
<div>
<PasswordInput
label={t('admin.settings.mail.password', 'SMTP Password')}
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
value={settings.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.mail.from', 'From Address')}
description={t('admin.settings.mail.from.description', 'Email address to use as sender')}
value={settings.from || ''}
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
placeholder="noreply@example.com"
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@ -0,0 +1,310 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '../../../toast';
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() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<PremiumSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/premium');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch premium 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 response = await fetch('/api/v1/admin/settings/section/premium', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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.premium.title', 'Premium & Enterprise')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.premium.description', 'Configure premium and enterprise features.')}
</Text>
</div>
{/* License */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License')}</Text>
<div>
<TextInput
label={t('admin.settings.premium.key', 'License Key')}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key')}
value={settings.key || ''}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.enabled', 'Enable Premium Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
</Text>
</div>
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
</div>
</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')}
</Button>
</Group>
</Stack>
);
}

View File

@ -0,0 +1,166 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '../../../toast';
interface PrivacySettingsData {
enableAnalytics?: boolean;
googleVisibility?: boolean;
metricsEnabled?: boolean;
}
export default function AdminPrivacySection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<PrivacySettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
// Fetch metrics and system sections
const [metricsResponse, systemResponse] = await Promise.all([
fetch('/api/v1/admin/settings/section/metrics'),
fetch('/api/v1/admin/settings/section/system')
]);
if (metricsResponse.ok && systemResponse.ok) {
const metrics = await metricsResponse.json();
const system = await systemResponse.json();
setSettings({
enableAnalytics: system.enableAnalytics || false,
googleVisibility: system.googlevisibility || false,
metricsEnabled: metrics.enabled || false
});
}
} catch (error) {
console.error('Failed to fetch privacy 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 {
// Use delta update endpoint with dot notation for cross-section settings
const deltaSettings = {
'system.enableAnalytics': settings.enableAnalytics,
'system.googlevisibility': settings.googleVisibility,
'metrics.enabled': settings.metricsEnabled
};
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.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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.privacy.title', 'Privacy')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.privacy.description', 'Configure privacy and data collection settings.')}
</Text>
</div>
{/* Analytics & Tracking */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.analytics', 'Analytics & Tracking')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics', 'Enable Analytics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')}
</Text>
</div>
<Switch
checked={settings.enableAnalytics || false}
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled', 'Enable Metrics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
</Text>
</div>
<Switch
checked={settings.metricsEnabled || false}
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
/>
</div>
</Stack>
</Paper>
{/* Search Engine Visibility */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.searchEngine', 'Search Engine Visibility')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility', 'Google Visibility')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
</Text>
</div>
<Switch
checked={settings.googleVisibility || false}
onChange={(e) => setSettings({ ...settings, googleVisibility: 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>
</Stack>
);
}

View File

@ -0,0 +1,276 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, PasswordInput } from '@mantine/core';
import { alert } from '../../../toast';
interface SecuritySettingsData {
enableLogin?: boolean;
csrfDisabled?: boolean;
loginMethod?: string;
loginAttemptCount?: number;
loginResetTimeMinutes?: number;
initialLogin?: {
username?: string;
password?: string;
};
jwt?: {
persistence?: boolean;
enableKeyRotation?: boolean;
enableKeyCleanup?: boolean;
keyRetentionDays?: number;
secureCookie?: boolean;
};
}
export default function AdminSecuritySection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<SecuritySettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/security');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch security 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 response = await fetch('/api/v1/admin/settings/section/security', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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.security.title', 'Security')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.security.description', 'Configure authentication, login behaviour, and security policies.')}
</Text>
</div>
{/* Authentication Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.authentication', 'Authentication')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.enableLogin', 'Enable Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')}
</Text>
</div>
<Switch
checked={settings.enableLogin || false}
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
/>
</div>
<div>
<Select
label={t('admin.settings.security.loginMethod', 'Login Method')}
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
value={settings.loginMethod || 'all'}
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
data={[
{ value: 'all', label: t('admin.settings.security.loginMethod.all', 'All Methods') },
{ value: 'normal', label: t('admin.settings.security.loginMethod.normal', 'Username/Password Only') },
{ value: 'oauth2', label: t('admin.settings.security.loginMethod.oauth2', 'OAuth2 Only') },
{ value: 'saml2', label: t('admin.settings.security.loginMethod.saml2', 'SAML2 Only') },
]}
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
value={settings.loginAttemptCount || 0}
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
min={0}
max={100}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
value={settings.loginResetTimeMinutes || 0}
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
min={0}
max={1440}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled', 'Disable CSRF Protection')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
</Text>
</div>
<Switch
checked={settings.csrfDisabled || false}
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
/>
</div>
</Stack>
</Paper>
{/* Initial Login Credentials */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.initialLogin', 'Initial Login')}</Text>
<div>
<TextInput
label={t('admin.settings.security.initialLogin.username', 'Initial Username')}
description={t('admin.settings.security.initialLogin.username.description', 'Default admin username for first-time setup')}
value={settings.initialLogin?.username || ''}
onChange={(e) => setSettings({ ...settings, initialLogin: { ...settings.initialLogin, username: e.target.value } })}
placeholder="admin"
/>
</div>
<div>
<PasswordInput
label={t('admin.settings.security.initialLogin.password', 'Initial Password')}
description={t('admin.settings.security.initialLogin.password.description', 'Default admin password for first-time setup')}
value={settings.initialLogin?.password || ''}
onChange={(e) => setSettings({ ...settings, initialLogin: { ...settings.initialLogin, password: e.target.value } })}
placeholder="••••••••"
/>
</div>
</Stack>
</Paper>
{/* JWT Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt', 'JWT Configuration')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence', 'Enable Key Persistence')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')}
</Text>
</div>
<Switch
checked={settings.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation', 'Enable Key Rotation')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup', 'Enable Key Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
min={1}
max={365}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie', 'Secure Cookie')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
</Text>
</div>
<Switch
checked={settings.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: 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>
</Stack>
);
}

View File

@ -13,7 +13,16 @@ export type NavKey =
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys';
| 'hotkeys'
| 'adminGeneral'
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminAdvanced'
| 'adminMail'
| 'adminLegal'
| 'adminPremium'
| 'adminEndpoints';
// some of these are not used yet, but appear in figma designs