diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 3bcc48715..272c0b35c 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -74,8 +74,7 @@ public class AppConfig { @Bean(name = "appName") public String appName() { - String homeTitle = applicationProperties.getUi().getAppName(); - return (homeTitle != null) ? homeTitle : "Stirling PDF"; + return "Stirling PDF"; } @Bean(name = "appVersion") @@ -93,9 +92,7 @@ public class AppConfig { @Bean(name = "homeText") public String homeText() { - return (applicationProperties.getUi().getHomeDescription() != null) - ? applicationProperties.getUi().getHomeDescription() - : "null"; + return "null"; } @Bean(name = "languages") @@ -110,11 +107,8 @@ public class AppConfig { @Bean(name = "navBarText") public String navBarText() { - String defaultNavBar = - applicationProperties.getUi().getAppNameNavbar() != null - ? applicationProperties.getUi().getAppNameNavbar() - : applicationProperties.getUi().getAppName(); - return (defaultNavBar != null) ? defaultNavBar : "Stirling PDF"; + String navBar = applicationProperties.getUi().getAppNameNavbar(); + return (navBar != null) ? navBar : "Stirling PDF"; } @Bean(name = "enableAlphaFunctionality") diff --git a/app/common/src/main/java/stirling/software/common/service/PostHogService.java b/app/common/src/main/java/stirling/software/common/service/PostHogService.java index 2bc219832..d9b8836f4 100644 --- a/app/common/src/main/java/stirling/software/common/service/PostHogService.java +++ b/app/common/src/main/java/stirling/software/common/service/PostHogService.java @@ -327,11 +327,6 @@ public class PostHogService { applicationProperties.getSystem().isAnalyticsEnabled()); // Capture UI properties - addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName()); - addIfNotEmpty( - properties, - "ui_homeDescription", - applicationProperties.getUi().getHomeDescription()); addIfNotEmpty( properties, "ui_appNameNavbar", applicationProperties.getUi().getAppNameNavbar()); diff --git a/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java index 3501a541a..691785187 100644 --- a/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java +++ b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java @@ -22,6 +22,8 @@ public class AppArgsCapture implements ApplicationRunner { @Override public void run(ApplicationArguments args) { APP_ARGS.set(List.of(args.getSourceArgs())); - log.debug("Captured {} application arguments for restart capability", args.getSourceArgs().length); + log.debug( + "Captured {} application arguments for restart capability", + args.getSourceArgs().length); } } diff --git a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java index b7d8e17be..738cde8e6 100644 --- a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java +++ b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java @@ -8,9 +8,7 @@ import java.nio.file.Paths; import lombok.extern.slf4j.Slf4j; -/** - * Utility class to locate JAR files at runtime for restart operations - */ +/** Utility class to locate JAR files at runtime for restart operations */ @Slf4j public class JarPathUtil { @@ -45,8 +43,8 @@ public class JarPathUtil { } /** - * Gets the path to the restart-helper.jar file Expected to be in the same directory as the - * main JAR + * Gets the path to the restart-helper.jar file Expected to be in the same directory as the main + * JAR * * @return Path to restart-helper.jar, or null if not found */ diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java index 66078099f..ba98bf0e1 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -112,19 +112,11 @@ class ApplicationPropertiesLogicTest { @Test void ui_getters_return_null_for_blank() { ApplicationProperties.Ui ui = new ApplicationProperties.Ui(); - ui.setAppName(" "); - ui.setHomeDescription(""); ui.setAppNameNavbar(null); - assertNull(ui.getAppName()); - assertNull(ui.getHomeDescription()); assertNull(ui.getAppNameNavbar()); - ui.setAppName("Stirling-PDF"); - ui.setHomeDescription("Home"); ui.setAppNameNavbar("Nav"); - assertEquals("Stirling-PDF", ui.getAppName()); - assertEquals("Home", ui.getHomeDescription()); assertEquals("Nav", ui.getAppNameNavbar()); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java index b316c8ae6..82daf89e7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java @@ -56,19 +56,22 @@ public class SettingsController { public ResponseEntity> getGeneralSettings() { Map 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() - )); + 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 updateGeneralSettings(@RequestBody Map settings) throws IOException { + public ResponseEntity updateGeneralSettings(@RequestBody Map settings) + throws IOException { // Update UI settings if (settings.containsKey("ui")) { Map ui = (Map) settings.get("ui"); @@ -83,23 +86,32 @@ public class SettingsController { Map system = (Map) settings.get("system"); if (system.containsKey("defaultLocale")) { GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale")); - applicationProperties.getSystem().setDefaultLocale((String) 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")); + 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")); + 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."); + return ResponseEntity.ok( + "General settings updated. Restart required for changes to take effect."); } // ========== SECURITY SETTINGS ========== @@ -115,61 +127,80 @@ public class SettingsController { 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() : "" - )); + 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() - )); + settings.put( + "jwt", + Map.of( + "enableKeystore", jwt.isEnableKeystore(), + "enableKeyRotation", jwt.isEnableKeyRotation(), + "enableKeyCleanup", jwt.isEnableKeyCleanup(), + "keyRetentionDays", jwt.getKeyRetentionDays())); return ResponseEntity.ok(settings); } @PostMapping("/admin/settings/security") @Hidden - public ResponseEntity updateSecuritySettings(@RequestBody Map settings) throws IOException { + public ResponseEntity updateSecuritySettings(@RequestBody Map settings) + throws IOException { if (settings.containsKey("enableLogin")) { GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin")); - applicationProperties.getSecurity().setEnableLogin((Boolean) 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")); + 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")); + 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")); + 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()); + GeneralUtils.saveKeyToSettings( + "security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes")); + applicationProperties + .getSecurity() + .setLoginResetTimeMinutes( + ((Number) settings.get("loginResetTimeMinutes")).longValue()); } // JWT settings if (settings.containsKey("jwt")) { Map jwt = (Map) 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")); + 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."); + return ResponseEntity.ok( + "Security settings updated. Restart required for changes to take effect."); } // ========== CONNECTIONS SETTINGS (OAuth/SAML) ========== @@ -182,66 +213,100 @@ public class SettingsController { // 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() : "" - )); + 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() - )); + 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 updateConnectionsSettings(@RequestBody Map settings) throws IOException { + public ResponseEntity updateConnectionsSettings( + @RequestBody Map settings) throws IOException { // OAuth2 settings if (settings.containsKey("oauth2")) { Map oauth2 = (Map) settings.get("oauth2"); if (oauth2.containsKey("enabled")) { GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled")); - applicationProperties.getSecurity().getOauth2().setEnabled((Boolean) 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")); + 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")); + 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")); + 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")); + 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")); + 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")); + 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")); + GeneralUtils.saveKeyToSettings( + "security.oauth2.useAsUsername", oauth2.get("useAsUsername")); + applicationProperties + .getSecurity() + .getOauth2() + .setUseAsUsername((String) oauth2.get("useAsUsername")); } } @@ -250,23 +315,38 @@ public class SettingsController { Map saml2 = (Map) settings.get("saml2"); if (saml2.containsKey("enabled")) { GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled")); - applicationProperties.getSecurity().getSaml2().setEnabled((Boolean) 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")); + 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")); + 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")); + 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."); + return ResponseEntity.ok( + "Connection settings updated. Restart required for changes to take effect."); } // ========== PRIVACY SETTINGS ========== @@ -285,21 +365,29 @@ public class SettingsController { @PostMapping("/admin/settings/privacy") @Hidden - public ResponseEntity updatePrivacySettings(@RequestBody Map settings) throws IOException { + public ResponseEntity updatePrivacySettings(@RequestBody Map settings) + throws IOException { if (settings.containsKey("enableAnalytics")) { - GeneralUtils.saveKeyToSettings("system.enableAnalytics", settings.get("enableAnalytics")); - applicationProperties.getSystem().setEnableAnalytics((Boolean) settings.get("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")); + 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."); + return ResponseEntity.ok( + "Privacy settings updated. Restart required for changes to take effect."); } // ========== ADVANCED SETTINGS ========== @@ -310,21 +398,29 @@ public class SettingsController { Map settings = new HashMap<>(); settings.put("endpoints", applicationProperties.getEndpoints()); - settings.put("enableAlphaFunctionality", applicationProperties.getSystem().getEnableAlphaFunctionality()); + 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()); + settings.put( + "tempFileManagement", applicationProperties.getSystem().getTempFileManagement()); return ResponseEntity.ok(settings); } @PostMapping("/admin/settings/advanced") @Hidden - public ResponseEntity updateAdvancedSettings(@RequestBody Map settings) throws IOException { + public ResponseEntity updateAdvancedSettings(@RequestBody Map settings) + throws IOException { if (settings.containsKey("enableAlphaFunctionality")) { - GeneralUtils.saveKeyToSettings("system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality")); - applicationProperties.getSystem().setEnableAlphaFunctionality((Boolean) settings.get("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")); @@ -332,9 +428,12 @@ public class SettingsController { } if (settings.containsKey("enableUrlToPDF")) { GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF")); - applicationProperties.getSystem().setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF")); + applicationProperties + .getSystem() + .setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF")); } - return ResponseEntity.ok("Advanced settings updated. Restart required for changes to take effect."); + return ResponseEntity.ok( + "Advanced settings updated. Restart required for changes to take effect."); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 39b4fe3d3..776fe4fa8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -63,12 +63,19 @@ public class ConfigController { // Check if user is admin based on authentication boolean isAdmin = false; try { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated() + Authentication authentication = + SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null + && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal())) { // Check if user has ROLE_ADMIN authority - isAdmin = authentication.getAuthorities().stream() - .anyMatch(auth -> Role.ADMIN.getRoleId().equals(auth.getAuthority())); + isAdmin = + authentication.getAuthorities().stream() + .anyMatch( + auth -> + Role.ADMIN + .getRoleId() + .equals(auth.getAuthority())); } } catch (Exception e) { // If security is not enabled or there's an error, isAdmin remains false diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 80b10b711..90f23b295 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -206,7 +206,8 @@ public class AdminSettingsController { @Operation( summary = "Get specific settings section", description = - "Retrieve settings for a specific section (e.g., security, system, ui). Admin access required.") + "Retrieve settings for a specific section (e.g., security, system, ui). " + + "By default includes pending changes with awaitingRestart flags. Admin access required.") @ApiResponses( value = { @ApiResponse( @@ -217,7 +218,9 @@ public class AdminSettingsController { responseCode = "403", description = "Access denied - Admin role required") }) - public ResponseEntity getSettingsSection(@PathVariable String sectionName) { + public ResponseEntity getSettingsSection( + @PathVariable String sectionName, + @RequestParam(defaultValue = "true") boolean includePending) { try { Object sectionData = getSectionData(sectionName); if (sectionData == null) { @@ -228,8 +231,24 @@ public class AdminSettingsController { + ". Valid sections: " + String.join(", ", VALID_SECTION_NAMES)); } - log.debug("Admin requested settings section: {}", sectionName); - return ResponseEntity.ok(sectionData); + + // Convert to Map for manipulation + @SuppressWarnings("unchecked") + Map sectionMap = objectMapper.convertValue(sectionData, Map.class); + + if (includePending && !pendingChanges.isEmpty()) { + // Add pending changes block for this section + Map sectionPending = extractPendingForSection(sectionName); + if (!sectionPending.isEmpty()) { + sectionMap.put("_pending", sectionPending); + } + } + + log.debug( + "Admin requested settings section: {} (includePending={})", + sectionName, + includePending); + return ResponseEntity.ok(sectionMap); } catch (IllegalArgumentException e) { log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e); return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -406,15 +425,11 @@ public class AdminSettingsController { "Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.") @ApiResponses( value = { - @ApiResponse( - responseCode = "200", - description = "Restart initiated successfully"), + @ApiResponse(responseCode = "200", description = "Restart initiated successfully"), @ApiResponse( responseCode = "403", description = "Access denied - Admin role required"), - @ApiResponse( - responseCode = "500", - description = "Failed to initiate restart") + @ApiResponse(responseCode = "500", description = "Failed to initiate restart") }) public ResponseEntity restartApplication() { try { @@ -434,8 +449,7 @@ public class AdminSettingsController { if (helperJar == null || !Files.isRegularFile(helperJar)) { log.error("Cannot restart: restart-helper.jar not found at expected location"); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body( - "Restart helper not found. Please restart the application manually."); + .body("Restart helper not found. Please restart the application manually."); } // Get current application arguments @@ -737,4 +751,62 @@ public class AdminSettingsController { return mergedSettings; } + + /** + * Extract pending changes for a specific section + * + * @param sectionName The section name (e.g., "security", "system") + * @return Map of pending changes with nested structure for this section + */ + @SuppressWarnings("unchecked") + private Map extractPendingForSection(String sectionName) { + Map result = new HashMap<>(); + String sectionPrefix = sectionName.toLowerCase() + "."; + + // Find all pending changes for this section + for (Map.Entry entry : pendingChanges.entrySet()) { + String pendingKey = entry.getKey(); + + if (pendingKey.toLowerCase().startsWith(sectionPrefix)) { + // Extract the path within the section (e.g., "security.enableLogin" -> + // "enableLogin") + String pathInSection = pendingKey.substring(sectionPrefix.length()); + Object pendingValue = entry.getValue(); + + // Build nested structure from dot notation + setNestedValue(result, pathInSection, pendingValue); + } + } + + return result; + } + + /** + * Set a value in a nested map using dot notation + * + * @param map The root map + * @param dotPath The dot notation path (e.g., "oauth2.clientSecret") + * @param value The value to set + */ + @SuppressWarnings("unchecked") + private void setNestedValue(Map map, String dotPath, Object value) { + String[] parts = dotPath.split("\\."); + Map current = map; + + // Navigate/create nested maps for all parts except the last + for (int i = 0; i < parts.length - 1; i++) { + String part = parts[i]; + Object nested = current.get(part); + + if (!(nested instanceof Map)) { + nested = new HashMap(); + current.put(part, nested); + } + + current = (Map) nested; + } + + // Set the final value + current.put(parts[parts.length - 1], value); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java index e1edb67d8..aca98eb75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -107,7 +107,8 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa public KeyStore getServerKeyStore() throws Exception { if (!hasProOrEnterpriseAccess()) { - throw new IllegalStateException("Server certificate feature requires Pro or Enterprise license"); + throw new IllegalStateException( + "Server certificate feature requires Pro or Enterprise license"); } if (!enabled || !hasServerCertificate()) { @@ -137,7 +138,8 @@ 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"); + throw new IllegalStateException( + "Server certificate feature requires Pro or Enterprise license"); } // Validate the uploaded certificate @@ -201,7 +203,8 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa private void generateServerCertificate() throws Exception { if (!hasProOrEnterpriseAccess()) { - throw new IllegalStateException("Server certificate feature requires Pro or Enterprise license"); + throw new IllegalStateException( + "Server certificate feature requires Pro or Enterprise license"); } // Generate key pair diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c16a2e867..d5fdab103 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3505,6 +3505,13 @@ "saveSuccess": "Settings saved successfully", "save": "Save Changes", "restartRequired": "Settings changes require a server restart to take effect.", + "restart": { + "title": "Restart Required", + "message": "Settings have been saved successfully. A server restart is required for the changes to take effect.", + "question": "Would you like to restart the server now or later?", + "now": "Restart Now", + "later": "Restart Later" + }, "general": { "title": "General", "description": "Configure general application settings including branding and default behaviour.", @@ -3770,6 +3777,10 @@ "legal": { "title": "Legal Documents", "description": "Configure links to legal documents and policies.", + "disclaimer": { + "title": "Legal Responsibility Warning", + "message": "By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations." + }, "termsAndConditions": "Terms and Conditions", "termsAndConditions.description": "URL or filename to terms and conditions", "privacyPolicy": "Privacy Policy", diff --git a/frontend/src/components/shared/config/PendingBadge.tsx b/frontend/src/components/shared/config/PendingBadge.tsx new file mode 100644 index 000000000..cdb3306f8 --- /dev/null +++ b/frontend/src/components/shared/config/PendingBadge.tsx @@ -0,0 +1,22 @@ +import { Badge } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +interface PendingBadgeProps { + show: boolean; + size?: 'xs' | 'sm' | 'md' | 'lg'; +} + +/** + * Badge to show when a setting has been saved but requires restart to take effect. + */ +export default function PendingBadge({ show, size = 'xs' }: PendingBadgeProps) { + const { t } = useTranslation(); + + if (!show) return null; + + return ( + + {t('admin.settings.restartRequired', 'Restart Required')} + + ); +} diff --git a/frontend/src/components/shared/config/RestartConfirmationModal.tsx b/frontend/src/components/shared/config/RestartConfirmationModal.tsx index 4f32626b0..d52535aef 100644 --- a/frontend/src/components/shared/config/RestartConfirmationModal.tsx +++ b/frontend/src/components/shared/config/RestartConfirmationModal.tsx @@ -2,6 +2,7 @@ import { Modal, Text, Group, Button, Stack } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import RefreshIcon from '@mui/icons-material/Refresh'; import ScheduleIcon from '@mui/icons-material/Schedule'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '../../../styles/zIndex'; interface RestartConfirmationModalProps { opened: boolean; @@ -27,6 +28,8 @@ export default function RestartConfirmationModal({ } centered size="md" + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + withinPortal > diff --git a/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx b/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx index f14ae50dd..50e0dd1a6 100644 --- a/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; +import apiClient from '../../../../services/apiClient'; interface AdvancedSettingsData { enableAlphaFunctionality?: boolean; @@ -51,26 +54,28 @@ interface AdvancedSettingsData { export default function AdminAdvancedSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - useEffect(() => { - fetchSettings(); - }, []); - - const fetchSettings = async () => { - try { + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'advanced', + fetchTransformer: async () => { const [systemResponse, processExecutorResponse] = await Promise.all([ - fetch('/api/v1/admin/settings/section/system'), - fetch('/api/v1/admin/settings/section/processExecutor') + apiClient.get('/api/v1/admin/settings/section/system'), + apiClient.get('/api/v1/admin/settings/section/processExecutor') ]); - const systemData = systemResponse.ok ? await systemResponse.json() : {}; - const processExecutorData = processExecutorResponse.ok ? await processExecutorResponse.json() : {}; + const systemData = systemResponse.data || {}; + const processExecutorData = processExecutorResponse.data || {}; - setSettings({ + const result: any = { enableAlphaFunctionality: systemData.enableAlphaFunctionality || false, maxDPI: systemData.maxDPI || 0, enableUrlToPDF: systemData.enableUrlToPDF || false, @@ -87,23 +92,39 @@ export default function AdminAdvancedSection() { cleanupSystemTemp: false }, processExecutor: processExecutorData || {} - }); - } 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 + // Merge pending blocks from both endpoints + const pendingBlock: any = {}; + if (systemData._pending?.enableAlphaFunctionality !== undefined) { + pendingBlock.enableAlphaFunctionality = systemData._pending.enableAlphaFunctionality; + } + if (systemData._pending?.maxDPI !== undefined) { + pendingBlock.maxDPI = systemData._pending.maxDPI; + } + if (systemData._pending?.enableUrlToPDF !== undefined) { + pendingBlock.enableUrlToPDF = systemData._pending.enableUrlToPDF; + } + if (systemData._pending?.tessdataDir !== undefined) { + pendingBlock.tessdataDir = systemData._pending.tessdataDir; + } + if (systemData._pending?.disableSanitize !== undefined) { + pendingBlock.disableSanitize = systemData._pending.disableSanitize; + } + if (systemData._pending?.tempFileManagement) { + pendingBlock.tempFileManagement = systemData._pending.tempFileManagement; + } + if (processExecutorData._pending) { + pendingBlock.processExecutor = processExecutorData._pending; + } + + if (Object.keys(pendingBlock).length > 0) { + result._pending = pendingBlock; + } + + return result; + }, + saveTransformer: (settings) => { const deltaSettings: Record = { 'system.enableAlphaFunctionality': settings.enableAlphaFunctionality, 'system.maxDPI': settings.maxDPI, @@ -136,25 +157,27 @@ export default function AdminAdvancedSection() { }); } - const response = await fetch('/api/v1/admin/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ settings: deltaSettings }), - }); + return { + sectionData: {}, + deltaSettings + }; + } + }); - if (response.ok) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + useEffect(() => { + fetchSettings(); + }, []); + + const handleSave = async () => { + try { + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -187,10 +210,13 @@ export default function AdminAdvancedSection() { {t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')} - setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })} - /> + + setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })} + /> + +
@@ -200,10 +226,13 @@ export default function AdminAdvancedSection() { {t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')}
- setSettings({ ...settings, enableUrlToPDF: e.target.checked })} - /> + + setSettings({ ...settings, enableUrlToPDF: e.target.checked })} + /> + +
@@ -213,10 +242,13 @@ export default function AdminAdvancedSection() { {t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')}
- setSettings({ ...settings, disableSanitize: e.target.checked })} - /> + + setSettings({ ...settings, disableSanitize: e.target.checked })} + /> + +
@@ -227,24 +259,32 @@ export default function AdminAdvancedSection() { {t('admin.settings.advanced.processing', 'Processing')}
- setSettings({ ...settings, maxDPI: Number(value) })} - min={0} - max={3000} - /> + + setSettings({ ...settings, maxDPI: Number(value) })} + min={0} + max={3000} + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, tessdataDir: e.target.value })} - placeholder="/usr/share/tessdata" - /> + + setSettings({ ...settings, tessdataDir: e.target.value })} + placeholder="/usr/share/tessdata" + style={{ flex: 1 }} + /> + +
@@ -346,13 +386,16 @@ export default function AdminAdvancedSection() { {t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')} - setSettings({ - ...settings, - tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked } - })} - /> + + setSettings({ + ...settings, + tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked } + })} + /> + +
@@ -362,13 +405,16 @@ export default function AdminAdvancedSection() { {t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.description', 'Whether to clean broader system temp directory (use with caution)')}
- setSettings({ - ...settings, - tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked } - })} - /> + + setSettings({ + ...settings, + tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked } + })} + /> + + diff --git a/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx index b9d0dda82..5bd92c978 100644 --- a/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; import ProviderCard from './ProviderCard'; import { ALL_PROVIDERS, @@ -12,6 +14,7 @@ import { SAML2_PROVIDER, Provider, } from './providerDefinitions'; +import apiClient from '../../../../services/apiClient'; interface ConnectionsSettingsData { oauth2?: { @@ -44,45 +47,70 @@ interface ConnectionsSettingsData { export default function AdminConnectionsSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - useEffect(() => { - fetchSettings(); - }, []); - - const fetchSettings = async () => { - try { + const { + settings, + setSettings, + loading, + fetchSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'connections', + fetchTransformer: async () => { // Fetch security settings (oauth2, saml2) - const securityResponse = await fetch('/api/v1/admin/settings/section/security'); - const securityData = securityResponse.ok ? await securityResponse.json() : {}; + const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security'); + const securityData = securityResponse.data || {}; // Fetch mail settings - const mailResponse = await fetch('/api/v1/admin/settings/section/mail'); - const mailData = mailResponse.ok ? await mailResponse.json() : {}; + const mailResponse = await apiClient.get('/api/v1/admin/settings/section/mail'); + const mailData = mailResponse.data || {}; // Fetch premium settings for SSO Auto Login - const premiumResponse = await fetch('/api/v1/admin/settings/section/premium'); - const premiumData = premiumResponse.ok ? await premiumResponse.json() : {}; + const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium'); + const premiumData = premiumResponse.data || {}; - setSettings({ + const result: any = { oauth2: securityData.oauth2 || {}, saml2: securityData.saml2 || {}, mail: mailData || {}, ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false - }); - } 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); + }; + + // Merge pending blocks from all three endpoints + const pendingBlock: any = {}; + if (securityData._pending?.oauth2) { + pendingBlock.oauth2 = securityData._pending.oauth2; + } + if (securityData._pending?.saml2) { + pendingBlock.saml2 = securityData._pending.saml2; + } + if (mailData._pending) { + pendingBlock.mail = mailData._pending; + } + if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) { + pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin; + } + + if (Object.keys(pendingBlock).length > 0) { + result._pending = pendingBlock; + } + + return result; + }, + saveTransformer: (settings) => { + // This section doesn't have a global save button + // Individual providers save through their own handlers + return { + sectionData: {}, + deltaSettings: {} + }; } - }; + }); + + useEffect(() => { + fetchSettings(); + }, []); const isProviderConfigured = (provider: Provider): boolean => { if (provider.id === 'saml2') { @@ -134,13 +162,9 @@ export default function AdminConnectionsSection() { try { if (provider.id === 'smtp') { // Mail settings use a different endpoint - const response = await fetch('/api/v1/admin/settings/section/mail', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(providerSettings), - }); + const response = await apiClient.put('/api/v1/admin/settings/section/mail', providerSettings); - if (response.ok) { + if (response.status === 200) { await fetchSettings(); // Refresh settings alert({ alertType: 'success', @@ -172,13 +196,9 @@ export default function AdminConnectionsSection() { }); } - const response = await fetch('/api/v1/admin/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ settings: deltaSettings }), - }); + const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); - if (response.ok) { + if (response.status === 200) { await fetchSettings(); // Refresh settings alert({ alertType: 'success', @@ -203,13 +223,9 @@ export default function AdminConnectionsSection() { try { if (provider.id === 'smtp') { // Mail settings use a different endpoint - const response = await fetch('/api/v1/admin/settings/section/mail', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: false }), - }); + const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false }); - if (response.ok) { + if (response.status === 200) { await fetchSettings(); alert({ alertType: 'success', @@ -234,13 +250,9 @@ export default function AdminConnectionsSection() { }); } - const response = await fetch('/api/v1/admin/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ settings: deltaSettings }), - }); + const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); - if (response.ok) { + if (response.status === 200) { await fetchSettings(); alert({ alertType: 'success', @@ -275,13 +287,9 @@ export default function AdminConnectionsSection() { '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 }), - }); + const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); - if (response.ok) { + if (response.status === 200) { alert({ alertType: 'success', title: t('admin.success', 'Success'), @@ -333,13 +341,16 @@ export default function AdminConnectionsSection() { {t('admin.settings.connections.ssoAutoLogin.description', 'Automatically redirect to SSO login when authentication is required')} - { - setSettings({ ...settings, ssoAutoLogin: e.target.checked }); - handleSSOAutoLoginSave(); - }} - /> + + { + setSettings({ ...settings, ssoAutoLogin: e.target.checked }); + handleSSOAutoLoginSave(); + }} + /> + + diff --git a/frontend/src/components/shared/config/configSections/AdminDatabaseSection.tsx b/frontend/src/components/shared/config/configSections/AdminDatabaseSection.tsx index b18f86462..bb4afc0be 100644 --- a/frontend/src/components/shared/config/configSections/AdminDatabaseSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminDatabaseSection.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, TextInput, PasswordInput, Select, Badge } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; +import apiClient from '../../../../services/apiClient'; interface DatabaseSettingsData { enableCustomDatabase?: boolean; @@ -18,21 +21,24 @@ interface DatabaseSettingsData { export default function AdminDatabaseSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - useEffect(() => { - fetchSettings(); - }, []); + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'database', + fetchTransformer: async () => { + const response = await apiClient.get('/api/v1/admin/settings/section/system'); + const systemData = response.data || {}; - const fetchSettings = async () => { - try { - const response = await fetch('/api/v1/admin/settings/section/system'); - const systemData = response.ok ? await response.json() : {}; - - setSettings(systemData.datasource || { + // Extract datasource from system response and handle pending + const datasource = systemData.datasource || { enableCustomDatabase: false, customDatabaseUrl: '', username: '', @@ -41,22 +47,18 @@ export default function AdminDatabaseSection() { 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 { + // Map pending changes from system._pending.datasource to root level + const result: any = { ...datasource }; + if (systemData._pending?.datasource) { + result._pending = systemData._pending.datasource; + } + + return result; + }, + saveTransformer: (settings) => { + // Convert flat settings to dot-notation for delta endpoint const deltaSettings: Record = { 'system.datasource.enableCustomDatabase': settings.enableCustomDatabase, 'system.datasource.customDatabaseUrl': settings.customDatabaseUrl, @@ -68,25 +70,27 @@ export default function AdminDatabaseSection() { '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 }), - }); + return { + sectionData: {}, + deltaSettings + }; + } + }); - if (response.ok) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + useEffect(() => { + fetchSettings(); + }, []); + + const handleSave = async () => { + try { + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; diff --git a/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx b/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx index b88c7938c..82dc15980 100644 --- a/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; interface EndpointsSettingsData { toRemove?: string[]; @@ -12,56 +14,34 @@ interface EndpointsSettingsData { export default function AdminEndpointsSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'endpoints', + }); + 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) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -134,31 +114,39 @@ export default function AdminEndpointsSection() { {t('admin.settings.endpoints.management', 'Endpoint Management')}
- setSettings({ ...settings, toRemove: value })} - data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))} - searchable - clearable - placeholder="Select endpoints to disable" - comboboxProps={{ zIndex: 1400 }} - /> + + setSettings({ ...settings, toRemove: value })} + data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))} + searchable + clearable + placeholder="Select endpoints to disable" + comboboxProps={{ zIndex: 1400 }} + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, groupsToRemove: value })} - data={commonGroups.map(group => ({ value: group, label: group }))} - searchable - clearable - placeholder="Select groups to disable" - comboboxProps={{ zIndex: 1400 }} - /> + + setSettings({ ...settings, groupsToRemove: value })} + data={commonGroups.map(group => ({ value: group, label: group }))} + searchable + clearable + placeholder="Select groups to disable" + comboboxProps={{ zIndex: 1400 }} + style={{ flex: 1 }} + /> + +
diff --git a/frontend/src/components/shared/config/configSections/AdminFeaturesSection.tsx b/frontend/src/components/shared/config/configSections/AdminFeaturesSection.tsx index 9f4660286..fa2f562ee 100644 --- a/frontend/src/components/shared/config/configSections/AdminFeaturesSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminFeaturesSection.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; +import apiClient from '../../../../services/apiClient'; interface FeaturesSettingsData { serverCertificate?: { @@ -16,44 +19,39 @@ interface FeaturesSettingsData { 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({}); - useEffect(() => { - fetchSettings(); - }, []); + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'features', + fetchTransformer: async () => { + const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system'); + const systemData = systemResponse.data || {}; - const fetchSettings = async () => { - try { - const systemResponse = await fetch('/api/v1/admin/settings/section/system'); - const systemData = systemResponse.ok ? await systemResponse.json() : {}; - - setSettings({ + const result: any = { 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 + // Map pending changes from system._pending.serverCertificate + if (systemData._pending?.serverCertificate) { + result._pending = { serverCertificate: systemData._pending.serverCertificate }; + } + + return result; + }, + saveTransformer: (settings) => { const deltaSettings: Record = {}; if (settings.serverCertificate) { @@ -63,25 +61,27 @@ export default function AdminFeaturesSection() { 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 }), - }); + return { + sectionData: {}, + deltaSettings + }; + } + }); - if (response.ok) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + useEffect(() => { + fetchSettings(); + }, []); + + const handleSave = async () => { + try { + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -121,40 +121,51 @@ export default function AdminFeaturesSection() { {t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')} - setSettings({ - ...settings, - serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked } - })} - /> + + setSettings({ + ...settings, + serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked } + })} + /> + +
- setSettings({ - ...settings, - serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value } - })} - placeholder="Stirling-PDF" - /> + + setSettings({ + ...settings, + serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value } + })} + placeholder="Stirling-PDF" + style={{ flex: 1 }} + /> + +
- setSettings({ - ...settings, - serverCertificate: { ...settings.serverCertificate, validity: Number(value) } - })} - min={1} - max={3650} - /> + + setSettings({ + ...settings, + serverCertificate: { ...settings.serverCertificate, validity: Number(value) } + })} + min={1} + max={3650} + style={{ flex: 1 }} + /> + +
@@ -164,13 +175,16 @@ export default function AdminFeaturesSection() { {t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
- setSettings({ - ...settings, - serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked } - })} - /> + + setSettings({ + ...settings, + serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked } + })} + /> + +
diff --git a/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx index 07f438367..1d33ffb69 100644 --- a/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; +import apiClient from '../../../../services/apiClient'; interface GeneralSettingsData { ui: { @@ -37,32 +40,30 @@ interface GeneralSettingsData { export default function AdminGeneralSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - const [settings, setSettings] = useState({ - ui: {}, - system: {}, - }); - useEffect(() => { - fetchSettings(); - }, []); - - const fetchSettings = async () => { - try { - // Fetch both ui and system sections from proprietary admin API + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'general', + fetchTransformer: async () => { 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/premium') + apiClient.get('/api/v1/admin/settings/section/ui'), + apiClient.get('/api/v1/admin/settings/section/system'), + apiClient.get('/api/v1/admin/settings/section/premium') ]); - const ui = uiResponse.ok ? await uiResponse.json() : {}; - const system = systemResponse.ok ? await systemResponse.json() : {}; - const premium = premiumResponse.ok ? await premiumResponse.json() : {}; + const ui = uiResponse.data || {}; + const system = systemResponse.data || {}; + const premium = premiumResponse.data || {}; - setSettings({ + const result: any = { ui, system, customPaths: system.customPaths || { @@ -81,45 +82,47 @@ export default function AdminGeneralSection() { creator: '', producer: '' } - }); - } 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), - }) - ]); + // Merge pending blocks from all three endpoints + const pendingBlock: any = {}; + if (ui._pending) { + pendingBlock.ui = ui._pending; + } + if (system._pending) { + pendingBlock.system = system._pending; + } + if (system._pending?.customPaths) { + pendingBlock.customPaths = system._pending.customPaths; + } + if (premium._pending?.proFeatures?.customMetadata) { + pendingBlock.customMetadata = premium._pending.proFeatures.customMetadata; + } - // Save custom metadata and custom paths via delta endpoint + if (Object.keys(pendingBlock).length > 0) { + result._pending = pendingBlock; + } + + return result; + }, + saveTransformer: (settings) => { const deltaSettings: Record = { + // UI settings + 'ui.appNameNavbar': settings.ui.appNameNavbar, + 'ui.languages': settings.ui.languages, + // System settings + 'system.defaultLocale': settings.system.defaultLocale, + 'system.showUpdate': settings.system.showUpdate, + 'system.showUpdateOnlyAdmin': settings.system.showUpdateOnlyAdmin, + 'system.customHTMLFiles': settings.system.customHTMLFiles, + 'system.fileUploadLimit': settings.system.fileUploadLimit, + // Premium custom metadata 'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata, 'premium.proFeatures.customMetadata.author': settings.customMetadata?.author, 'premium.proFeatures.customMetadata.creator': settings.customMetadata?.creator, 'premium.proFeatures.customMetadata.producer': settings.customMetadata?.producer }; - // 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; @@ -127,26 +130,27 @@ export default function AdminGeneralSection() { 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 }), - }); + return { + sectionData: {}, + deltaSettings + }; + } + }); - if (uiResponse.ok && systemResponse.ok && deltaResponse.ok) { - // Show restart confirmation modal - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + useEffect(() => { + fetchSettings(); + }, []); + + const handleSave = async () => { + try { + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; diff --git a/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx b/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx index 364990041..c1bfe9840 100644 --- a/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx @@ -1,10 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core'; import WarningIcon from '@mui/icons-material/Warning'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; interface LegalSettingsData { termsAndConditions?: string; @@ -16,56 +18,34 @@ interface LegalSettingsData { export default function AdminLegalSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'legal', + }); + 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) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -104,53 +84,73 @@ export default function AdminLegalSection() {
- setSettings({ ...settings, termsAndConditions: e.target.value })} - placeholder="https://example.com/terms" - /> + + setSettings({ ...settings, termsAndConditions: e.target.value })} + placeholder="https://example.com/terms" + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, privacyPolicy: e.target.value })} - placeholder="https://example.com/privacy" - /> + + setSettings({ ...settings, privacyPolicy: e.target.value })} + placeholder="https://example.com/privacy" + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, accessibilityStatement: e.target.value })} - placeholder="https://example.com/accessibility" - /> + + setSettings({ ...settings, accessibilityStatement: e.target.value })} + placeholder="https://example.com/accessibility" + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, cookiePolicy: e.target.value })} - placeholder="https://example.com/cookies" - /> + + setSettings({ ...settings, cookiePolicy: e.target.value })} + placeholder="https://example.com/cookies" + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, impressum: e.target.value })} - placeholder="https://example.com/impressum" - /> + + setSettings({ ...settings, impressum: e.target.value })} + placeholder="https://example.com/impressum" + style={{ flex: 1 }} + /> + +
diff --git a/frontend/src/components/shared/config/configSections/AdminMailSection.tsx b/frontend/src/components/shared/config/configSections/AdminMailSection.tsx index c44da4c43..778173960 100644 --- a/frontend/src/components/shared/config/configSections/AdminMailSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminMailSection.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; interface MailSettingsData { enabled?: boolean; @@ -16,56 +18,34 @@ interface MailSettingsData { export default function AdminMailSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'mail', + }); + 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) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -95,59 +75,82 @@ export default function AdminMailSection() { {t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')} - setSettings({ ...settings, enabled: e.target.checked })} - /> + + setSettings({ ...settings, enabled: e.target.checked })} + /> + +
- setSettings({ ...settings, host: e.target.value })} - placeholder="smtp.example.com" - /> + + setSettings({ ...settings, host: e.target.value })} + placeholder="smtp.example.com" + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, port: Number(value) })} - min={1} - max={65535} - /> + + setSettings({ ...settings, port: Number(value) })} + min={1} + max={65535} + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, username: e.target.value })} - /> + + setSettings({ ...settings, username: e.target.value })} + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, password: e.target.value })} - /> + + setSettings({ ...settings, password: e.target.value })} + style={{ flex: 1 }} + /> + +
- setSettings({ ...settings, from: e.target.value })} - placeholder="noreply@example.com" - /> + + setSettings({ ...settings, from: e.target.value })} + placeholder="noreply@example.com" + style={{ flex: 1 }} + /> + +
diff --git a/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx b/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx index 3d2c8e059..bdc8e0aef 100644 --- a/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx @@ -1,10 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; 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'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; interface PremiumSettingsData { key?: string; @@ -13,56 +15,34 @@ interface PremiumSettingsData { export default function AdminPremiumSection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'premium', + }); + 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) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -107,13 +87,17 @@ export default function AdminPremiumSection() { {t('admin.settings.premium.license', 'License Configuration')}
- setSettings({ ...settings, key: e.target.value })} - placeholder="00000000-0000-0000-0000-000000000000" - /> + + setSettings({ ...settings, key: e.target.value })} + placeholder="00000000-0000-0000-0000-000000000000" + style={{ flex: 1 }} + /> + +
@@ -123,10 +107,13 @@ export default function AdminPremiumSection() { {t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
- setSettings({ ...settings, enabled: e.target.checked })} - /> + + setSettings({ ...settings, enabled: e.target.checked })} + /> + + diff --git a/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx b/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx index fb396788b..8f410d4a0 100644 --- a/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core'; import { alert } from '../../../toast'; import RestartConfirmationModal from '../RestartConfirmationModal'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; +import apiClient from '../../../../services/apiClient'; interface PrivacySettingsData { enableAnalytics?: boolean; @@ -13,74 +16,79 @@ interface PrivacySettingsData { export default function AdminPrivacySection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [settings, setSettings] = useState({}); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - useEffect(() => { - fetchSettings(); - }, []); - - const fetchSettings = async () => { - try { - // Fetch metrics and system sections + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'privacy', + fetchTransformer: async () => { const [metricsResponse, systemResponse] = await Promise.all([ - fetch('/api/v1/admin/settings/section/metrics'), - fetch('/api/v1/admin/settings/section/system') + apiClient.get('/api/v1/admin/settings/section/metrics'), + apiClient.get('/api/v1/admin/settings/section/system') ]); - if (metricsResponse.ok && systemResponse.ok) { - const metrics = await metricsResponse.json(); - const system = await systemResponse.json(); + const metrics = metricsResponse.data; + const system = systemResponse.data; - setSettings({ - enableAnalytics: system.enableAnalytics || false, - googleVisibility: system.googlevisibility || false, - metricsEnabled: metrics.enabled || false - }); + const result: any = { + enableAnalytics: system.enableAnalytics || false, + googleVisibility: system.googlevisibility || false, + metricsEnabled: metrics.enabled || false + }; + + // Merge pending blocks from both endpoints + const pendingBlock: any = {}; + if (system._pending?.enableAnalytics !== undefined) { + pendingBlock.enableAnalytics = system._pending.enableAnalytics; + } + if (system._pending?.googlevisibility !== undefined) { + pendingBlock.googleVisibility = system._pending.googlevisibility; + } + if (metrics._pending?.enabled !== undefined) { + pendingBlock.metricsEnabled = metrics._pending.enabled; } - } 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 + if (Object.keys(pendingBlock).length > 0) { + result._pending = pendingBlock; + } + + return result; + }, + saveTransformer: (settings) => { const deltaSettings = { 'system.enableAnalytics': settings.enableAnalytics, 'system.googlevisibility': settings.googleVisibility, 'metrics.enabled': settings.metricsEnabled }; - const response = await fetch('/api/v1/admin/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ settings: deltaSettings }), - }); + return { + sectionData: {}, + deltaSettings + }; + } + }); - if (response.ok) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + useEffect(() => { + fetchSettings(); + }, []); + + const handleSave = async () => { + try { + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -113,10 +121,13 @@ export default function AdminPrivacySection() { {t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')} - setSettings({ ...settings, enableAnalytics: e.target.checked })} - /> + + setSettings({ ...settings, enableAnalytics: e.target.checked })} + /> + +
@@ -126,10 +137,13 @@ export default function AdminPrivacySection() { {t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
- setSettings({ ...settings, metricsEnabled: e.target.checked })} - /> + + setSettings({ ...settings, metricsEnabled: e.target.checked })} + /> + + @@ -146,10 +160,13 @@ export default function AdminPrivacySection() { {t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')} - setSettings({ ...settings, googleVisibility: e.target.checked })} - /> + + setSettings({ ...settings, googleVisibility: e.target.checked })} + /> + + diff --git a/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx index b1b4dec2f..6f13b15be 100644 --- a/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx @@ -1,10 +1,13 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; 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'; import { useRestartServer } from '../useRestartServer'; +import { useAdminSettings } from '../../../../hooks/useAdminSettings'; +import PendingBadge from '../PendingBadge'; +import apiClient from '../../../../services/apiClient'; interface SecuritySettingsData { enableLogin?: boolean; @@ -41,36 +44,41 @@ interface SecuritySettingsData { export default function AdminSecuritySection() { const { t } = useTranslation(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - const [settings, setSettings] = useState({}); - useEffect(() => { - fetchSettings(); - }, []); + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'security', + fetchTransformer: async () => { + const [securityResponse, premiumResponse, systemResponse] = await Promise.all([ + apiClient.get('/api/v1/admin/settings/section/security'), + apiClient.get('/api/v1/admin/settings/section/premium'), + apiClient.get('/api/v1/admin/settings/section/system') + ]); - const fetchSettings = async () => { - try { - const securityResponse = await fetch('/api/v1/admin/settings/section/security'); - const securityData = securityResponse.ok ? await securityResponse.json() : {}; + const securityData = securityResponse.data || {}; + const premiumData = premiumResponse.data || {}; + const systemData = systemResponse.data || {}; - // Fetch premium settings for audit logging - const premiumResponse = await fetch('/api/v1/admin/settings/section/premium'); - const premiumData = premiumResponse.ok ? await premiumResponse.json() : {}; + const { _pending: securityPending, ...securityActive } = securityData; + const { _pending: premiumPending, ...premiumActive } = premiumData; + const { _pending: systemPending, ...systemActive } = systemData; - // 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 || { + const combined: any = { + ...securityActive, + audit: premiumActive.enterpriseFeatures?.audit || { enabled: false, level: 2, retentionDays: 90 }, - html: systemData.html || { + html: systemActive.html || { urlSecurity: { enabled: true, level: 'MEDIUM', @@ -83,39 +91,35 @@ export default function AdminSecuritySection() { blockCloudMetadata: true } } - }); - } 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 { - // Save security settings (excluding audit and html) + // Merge all _pending blocks + const mergedPending: any = {}; + if (securityPending) { + Object.assign(mergedPending, securityPending); + } + if (premiumPending?.enterpriseFeatures?.audit) { + mergedPending.audit = premiumPending.enterpriseFeatures.audit; + } + if (systemPending?.html) { + mergedPending.html = systemPending.html; + } + + if (Object.keys(mergedPending).length > 0) { + combined._pending = mergedPending; + } + + return combined; + }, + saveTransformer: (settings) => { const { audit, html, ...securitySettings } = settings; - const securityResponse = await fetch('/api/v1/admin/settings/section/security', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(securitySettings), - }); - - // Save audit settings via delta endpoint const deltaSettings: Record = { '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; @@ -128,25 +132,27 @@ export default function AdminSecuritySection() { 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 }), - }); + return { + sectionData: securitySettings, + deltaSettings + }; + } + }); - if (securityResponse.ok && deltaResponse.ok) { - showRestartModal(); - } else { - throw new Error('Failed to save'); - } + useEffect(() => { + fetchSettings(); + }, []); + + const handleSave = async () => { + try { + await saveSettings(); + showRestartModal(); } catch (error) { alert({ alertType: 'error', title: t('admin.error', 'Error'), body: t('admin.settings.saveError', 'Failed to save settings'), }); - } finally { - setSaving(false); } }; @@ -179,10 +185,13 @@ export default function AdminSecuritySection() { {t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')} - setSettings({ ...settings, enableLogin: e.target.checked })} - /> + + setSettings({ ...settings, enableLogin: e.target.checked })} + /> + +
@@ -199,6 +208,11 @@ export default function AdminSecuritySection() { ]} comboboxProps={{ zIndex: 1400 }} /> + {isFieldPending(rawSettings, 'loginMethod') && ( + + + + )}
@@ -230,10 +244,13 @@ export default function AdminSecuritySection() { {t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
- setSettings({ ...settings, csrfDisabled: e.target.checked })} - /> + + setSettings({ ...settings, csrfDisabled: e.target.checked })} + /> + + @@ -262,10 +279,13 @@ export default function AdminSecuritySection() { {t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')} - setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })} - /> + + setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })} + /> + +
@@ -312,10 +332,13 @@ export default function AdminSecuritySection() { {t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
- setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })} - /> + + setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })} + /> + + @@ -335,10 +358,13 @@ export default function AdminSecuritySection() { {t('admin.settings.security.audit.enabled.description', 'Track user actions and system events for compliance and security monitoring')} - setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })} - /> + + setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })} + /> + +
@@ -382,16 +408,19 @@ export default function AdminSecuritySection() { {t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')}
- setSettings({ - ...settings, - html: { - ...settings.html, - urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked } - } - })} - /> + + setSettings({ + ...settings, + html: { + ...settings.html, + urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked } + } + })} + /> + +
diff --git a/frontend/src/hooks/useAdminSettings.ts b/frontend/src/hooks/useAdminSettings.ts new file mode 100644 index 000000000..875311f51 --- /dev/null +++ b/frontend/src/hooks/useAdminSettings.ts @@ -0,0 +1,191 @@ +import { useState } from 'react'; +import apiClient from '../services/apiClient'; +import { mergePendingSettings, isFieldPending, hasPendingChanges, getCurrentValue } from '../utils/settingsPendingHelper'; + +interface UseAdminSettingsOptions { + sectionName: string; + /** + * Optional transformer to combine data from multiple endpoints. + * If not provided, uses the section response directly. + */ + fetchTransformer?: () => Promise; + /** + * Optional transformer to split settings before saving. + * Returns an object with sectionData and optionally deltaSettings. + */ + saveTransformer?: (settings: T) => { + sectionData: any; + deltaSettings?: Record; + }; +} + +interface UseAdminSettingsReturn { + settings: T; + rawSettings: any; + loading: boolean; + saving: boolean; + setSettings: (settings: T) => void; + fetchSettings: () => Promise; + saveSettings: () => Promise; + isFieldPending: (fieldPath: string) => boolean; + hasPendingChanges: () => boolean; +} + +/** + * Hook for managing admin settings with automatic pending changes support. + * Includes delta detection to only send changed fields. + * + * @example + * const { settings, setSettings, saveSettings, isFieldPending } = useAdminSettings({ + * sectionName: 'legal' + * }); + */ +export function useAdminSettings( + options: UseAdminSettingsOptions +): UseAdminSettingsReturn { + const { sectionName, fetchTransformer, saveTransformer } = options; + + const [settings, setSettings] = useState({} as T); + const [rawSettings, setRawSettings] = useState(null); + const [originalSettings, setOriginalSettings] = useState({} as T); // Track original active values + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const fetchSettings = async () => { + try { + setLoading(true); + + let rawData: any; + + if (fetchTransformer) { + // Use custom fetch logic for complex sections + rawData = await fetchTransformer(); + } else { + // Simple single-endpoint fetch + const response = await apiClient.get(`/api/v1/admin/settings/section/${sectionName}`); + rawData = response.data || {}; + } + + console.log(`[useAdminSettings:${sectionName}] Raw response:`, JSON.stringify(rawData, null, 2)); + + // Store raw settings (includes _pending if present) + setRawSettings(rawData); + + // Extract active settings (without _pending) for delta comparison + const { _pending, ...activeOnly } = rawData; + setOriginalSettings(activeOnly as T); + console.log(`[useAdminSettings:${sectionName}] Original active settings:`, JSON.stringify(activeOnly, null, 2)); + + // Merge pending changes into settings for display + const mergedSettings = mergePendingSettings(rawData); + console.log(`[useAdminSettings:${sectionName}] Merged settings:`, JSON.stringify(mergedSettings, null, 2)); + + setSettings(mergedSettings as T); + } catch (error) { + console.error(`[useAdminSettings:${sectionName}] Failed to fetch:`, error); + throw error; + } finally { + setLoading(false); + } + }; + + const saveSettings = async () => { + try { + setSaving(true); + + // Compute delta: only include fields that changed from original + const delta = computeDelta(originalSettings, settings); + console.log(`[useAdminSettings:${sectionName}] Delta (changed fields):`, JSON.stringify(delta, null, 2)); + + if (Object.keys(delta).length === 0) { + console.log(`[useAdminSettings:${sectionName}] No changes detected, skipping save`); + return; + } + + if (saveTransformer) { + // Use custom save logic for complex sections + const { sectionData, deltaSettings } = saveTransformer(settings); + + // Save section data (with delta applied) + const sectionDelta = computeDelta(originalSettings, sectionData); + if (Object.keys(sectionDelta).length > 0) { + await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta); + } + + // Save delta settings if provided + if (deltaSettings && Object.keys(deltaSettings).length > 0) { + await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); + } + } else { + // Simple single-endpoint save with delta + await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, delta); + } + + // Refetch to get updated _pending block + await fetchSettings(); + } catch (error) { + console.error(`[useAdminSettings:${sectionName}] Failed to save:`, error); + throw error; + } finally { + setSaving(false); + } + }; + + return { + settings, + rawSettings, + loading, + saving, + setSettings, + fetchSettings, + saveSettings, + isFieldPending: (fieldPath: string) => isFieldPending(rawSettings, fieldPath), + hasPendingChanges: () => hasPendingChanges(rawSettings), + }; +} + +/** + * Compute delta between original and current settings. + * Returns only fields that have changed. + */ +function computeDelta(original: any, current: any): any { + const delta: any = {}; + + for (const key in current) { + if (!current.hasOwnProperty(key)) continue; + + const originalValue = original[key]; + const currentValue = current[key]; + + // Handle nested objects + if (isPlainObject(currentValue) && isPlainObject(originalValue)) { + const nestedDelta = computeDelta(originalValue, currentValue); + if (Object.keys(nestedDelta).length > 0) { + delta[key] = nestedDelta; + } + } + // Handle arrays + else if (Array.isArray(currentValue) && Array.isArray(originalValue)) { + if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) { + delta[key] = currentValue; + } + } + // Handle primitives + else if (currentValue !== originalValue) { + delta[key] = currentValue; + } + } + + return delta; +} + +/** + * Check if value is a plain object (not array, not null, not Date, etc.) + */ +function isPlainObject(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + value.constructor === Object + ); +} diff --git a/frontend/src/styles/zIndex.ts b/frontend/src/styles/zIndex.ts index 289ac100d..adf5f369b 100644 --- a/frontend/src/styles/zIndex.ts +++ b/frontend/src/styles/zIndex.ts @@ -4,10 +4,13 @@ export const Z_INDEX_FULLSCREEN_SURFACE = 1000; export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300; -export const Z_INDEX_FILE_MANAGER_MODAL = 1200; +export const Z_INDEX_FILE_MANAGER_MODAL = 1200; export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300; export const Z_INDEX_AUTOMATE_MODAL = 1100; +// Modal that appears on top of config modal (e.g., restart confirmation) +export const Z_INDEX_OVER_CONFIG_MODAL = 2000; + diff --git a/frontend/src/utils/settingsPendingHelper.ts b/frontend/src/utils/settingsPendingHelper.ts new file mode 100644 index 000000000..89a4991da --- /dev/null +++ b/frontend/src/utils/settingsPendingHelper.ts @@ -0,0 +1,166 @@ +/** + * Helper utilities for handling settings with pending changes that require restart. + * + * Backend returns settings in this format: + * { + * "enableLogin": false, // Current active value + * "csrfDisabled": true, + * "_pending": { // Optional - only present if there are pending changes + * "enableLogin": true // Value that will be active after restart + * } + * } + */ + +export interface SettingsWithPending { + _pending?: Partial; + [key: string]: any; +} + +/** + * Merge pending changes into the settings object. + * Returns a new object with pending values overlaid on top of current values. + * + * @param settings Settings object from backend (may contain _pending block) + * @returns Merged settings with pending values applied + */ +export function mergePendingSettings(settings: T): Omit { + if (!settings || !settings._pending) { + // No pending changes, return as-is (without _pending property) + const { _pending, ...rest } = settings || {}; + return rest as Omit; + } + + // Deep merge pending changes + const merged = deepMerge(settings, settings._pending); + + // Remove _pending from result + const { _pending, ...result } = merged; + return result as Omit; +} + +/** + * Check if a specific field has a pending change awaiting restart. + * + * @param settings Settings object from backend + * @param fieldPath Dot-notation path to the field (e.g., "oauth2.clientSecret") + * @returns True if field has pending changes + */ +export function isFieldPending( + settings: T | null | undefined, + fieldPath: string +): boolean { + if (!settings?._pending) { + return false; + } + + // Navigate the pending object using dot notation + const value = getNestedValue(settings._pending, fieldPath); + return value !== undefined; +} + +/** + * Check if there are any pending changes in the settings. + * + * @param settings Settings object from backend + * @returns True if there are any pending changes + */ +export function hasPendingChanges( + settings: T | null | undefined +): boolean { + return settings?._pending !== undefined && Object.keys(settings._pending).length > 0; +} + +/** + * Get the pending value for a specific field, or undefined if no pending change. + * + * @param settings Settings object from backend + * @param fieldPath Dot-notation path to the field + * @returns Pending value or undefined + */ +export function getPendingValue( + settings: T | null | undefined, + fieldPath: string +): any { + if (!settings?._pending) { + return undefined; + } + + return getNestedValue(settings._pending, fieldPath); +} + +/** + * Get the current active value for a field (ignoring pending changes). + * + * @param settings Settings object from backend + * @param fieldPath Dot-notation path to the field + * @returns Current active value + */ +export function getCurrentValue( + settings: T | null | undefined, + fieldPath: string +): any { + if (!settings) { + return undefined; + } + + // Get from settings, ignoring _pending + const { _pending, ...activeSettings } = settings; + return getNestedValue(activeSettings, fieldPath); +} + +// ========== Helper Functions ========== + +/** + * Deep merge two objects. Second object takes priority. + */ +function deepMerge(target: any, source: any): any { + if (!source) return target; + if (!target) return source; + + const result = { ...target }; + + for (const key in source) { + if (source.hasOwnProperty(key)) { + const sourceValue = source[key]; + const targetValue = result[key]; + + if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { + result[key] = deepMerge(targetValue, sourceValue); + } else { + result[key] = sourceValue; + } + } + } + + return result; +} + +/** + * Get nested value using dot notation. + */ +function getNestedValue(obj: any, path: string): any { + if (!obj || !path) return undefined; + + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined; + } + current = current[part]; + } + + return current; +} + +/** + * Check if value is a plain object (not array, not null, not Date, etc.) + */ +function isPlainObject(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + value.constructor === Object + ); +}