mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
fixes
This commit is contained in:
parent
928a591839
commit
e4db9e183d
@ -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")
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -56,19 +56,22 @@ public class SettingsController {
|
||||
public ResponseEntity<Map<String, Object>> getGeneralSettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
settings.put("ui", applicationProperties.getUi());
|
||||
settings.put("system", Map.of(
|
||||
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
|
||||
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
|
||||
"showUpdateOnlyAdmin", applicationProperties.getSystem().getShowUpdateOnlyAdmin(),
|
||||
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
|
||||
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()
|
||||
));
|
||||
settings.put(
|
||||
"system",
|
||||
Map.of(
|
||||
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
|
||||
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
|
||||
"showUpdateOnlyAdmin",
|
||||
applicationProperties.getSystem().getShowUpdateOnlyAdmin(),
|
||||
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
|
||||
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/general")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings) throws IOException {
|
||||
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
// Update UI settings
|
||||
if (settings.containsKey("ui")) {
|
||||
Map<String, String> ui = (Map<String, String>) settings.get("ui");
|
||||
@ -83,23 +86,32 @@ public class SettingsController {
|
||||
Map<String, Object> system = (Map<String, Object>) settings.get("system");
|
||||
if (system.containsKey("defaultLocale")) {
|
||||
GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale"));
|
||||
applicationProperties.getSystem().setDefaultLocale((String) system.get("defaultLocale"));
|
||||
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<String> updateSecuritySettings(@RequestBody Map<String, Object> settings) throws IOException {
|
||||
public ResponseEntity<String> updateSecuritySettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableLogin")) {
|
||||
GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin"));
|
||||
applicationProperties.getSecurity().setEnableLogin((Boolean) settings.get("enableLogin"));
|
||||
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<String, Object> jwt = (Map<String, Object>) settings.get("jwt");
|
||||
if (jwt.containsKey("secureCookie")) {
|
||||
GeneralUtils.saveKeyToSettings("security.jwt.secureCookie", jwt.get("secureCookie"));
|
||||
applicationProperties.getSecurity().getJwt().setSecureCookie((Boolean) jwt.get("secureCookie"));
|
||||
}
|
||||
if (jwt.containsKey("keyRetentionDays")) {
|
||||
GeneralUtils.saveKeyToSettings("security.jwt.keyRetentionDays", jwt.get("keyRetentionDays"));
|
||||
applicationProperties.getSecurity().getJwt().setKeyRetentionDays((Integer) jwt.get("keyRetentionDays"));
|
||||
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<String> updateConnectionsSettings(@RequestBody Map<String, Object> settings) throws IOException {
|
||||
public ResponseEntity<String> updateConnectionsSettings(
|
||||
@RequestBody Map<String, Object> settings) throws IOException {
|
||||
// OAuth2 settings
|
||||
if (settings.containsKey("oauth2")) {
|
||||
Map<String, Object> oauth2 = (Map<String, Object>) settings.get("oauth2");
|
||||
if (oauth2.containsKey("enabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled"));
|
||||
applicationProperties.getSecurity().getOauth2().setEnabled((Boolean) oauth2.get("enabled"));
|
||||
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<String, Object> saml2 = (Map<String, Object>) settings.get("saml2");
|
||||
if (saml2.containsKey("enabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled"));
|
||||
applicationProperties.getSecurity().getSaml2().setEnabled((Boolean) saml2.get("enabled"));
|
||||
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<String> updatePrivacySettings(@RequestBody Map<String, Object> settings) throws IOException {
|
||||
public ResponseEntity<String> updatePrivacySettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableAnalytics")) {
|
||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", settings.get("enableAnalytics"));
|
||||
applicationProperties.getSystem().setEnableAnalytics((Boolean) settings.get("enableAnalytics"));
|
||||
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<String, Object> 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<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings) throws IOException {
|
||||
public ResponseEntity<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableAlphaFunctionality")) {
|
||||
GeneralUtils.saveKeyToSettings("system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality"));
|
||||
applicationProperties.getSystem().setEnableAlphaFunctionality((Boolean) settings.get("enableAlphaFunctionality"));
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<String, Object> sectionMap = objectMapper.convertValue(sectionData, Map.class);
|
||||
|
||||
if (includePending && !pendingChanges.isEmpty()) {
|
||||
// Add pending changes block for this section
|
||||
Map<String, Object> 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<String> 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<String, Object> extractPendingForSection(String sectionName) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String sectionPrefix = sectionName.toLowerCase() + ".";
|
||||
|
||||
// Find all pending changes for this section
|
||||
for (Map.Entry<String, Object> 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<String, Object> map, String dotPath, Object value) {
|
||||
String[] parts = dotPath.split("\\.");
|
||||
Map<String, Object> 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<String, Object>();
|
||||
current.put(part, nested);
|
||||
}
|
||||
|
||||
current = (Map<String, Object>) nested;
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
current.put(parts[parts.length - 1], value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
22
frontend/src/components/shared/config/PendingBadge.tsx
Normal file
22
frontend/src/components/shared/config/PendingBadge.tsx
Normal file
@ -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 (
|
||||
<Badge color="orange" size={size} variant="light">
|
||||
{t('admin.settings.restartRequired', 'Restart Required')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Text size="sm">
|
||||
|
||||
@ -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<AdvancedSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<AdvancedSettingsData>({
|
||||
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<string, any> = {
|
||||
'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)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableAlphaFunctionality || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableAlphaFunctionality || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableAlphaFunctionality')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@ -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)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableUrlToPDF || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableUrlToPDF || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableUrlToPDF')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@ -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)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.disableSanitize || false}
|
||||
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.disableSanitize || false}
|
||||
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('disableSanitize')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@ -227,24 +259,32 @@ export default function AdminAdvancedSection() {
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.processing', 'Processing')}</Text>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.maxDPI', 'Maximum DPI')}
|
||||
description={t('admin.settings.advanced.maxDPI.description', 'Maximum DPI for image processing (0 = unlimited)')}
|
||||
value={settings.maxDPI || 0}
|
||||
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
|
||||
min={0}
|
||||
max={3000}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<NumberInput
|
||||
label={t('admin.settings.advanced.maxDPI', 'Maximum DPI')}
|
||||
description={t('admin.settings.advanced.maxDPI.description', 'Maximum DPI for image processing (0 = unlimited)')}
|
||||
value={settings.maxDPI || 0}
|
||||
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
|
||||
min={0}
|
||||
max={3000}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('maxDPI')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}
|
||||
description={t('admin.settings.advanced.tessdataDir.description', 'Path to the directory containing Tessdata files for OCR')}
|
||||
value={settings.tessdataDir || ''}
|
||||
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
|
||||
placeholder="/usr/share/tessdata"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}
|
||||
description={t('admin.settings.advanced.tessdataDir.description', 'Path to the directory containing Tessdata files for OCR')}
|
||||
value={settings.tessdataDir || ''}
|
||||
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
|
||||
placeholder="/usr/share/tessdata"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('tessdataDir')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@ -346,13 +386,16 @@ export default function AdminAdvancedSection() {
|
||||
{t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.startupCleanup ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.startupCleanup ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@ -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)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@ -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<ConnectionsSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
fetchSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<ConnectionsSettingsData>({
|
||||
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')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.ssoAutoLogin || false}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
||||
handleSSOAutoLoginSave();
|
||||
}}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.ssoAutoLogin || false}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
||||
handleSSOAutoLoginSave();
|
||||
}}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@ -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<DatabaseSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<DatabaseSettingsData>({
|
||||
sectionName: 'database',
|
||||
fetchTransformer: async () => {
|
||||
const response = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||
const systemData = response.data || {};
|
||||
|
||||
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<string, any> = {
|
||||
'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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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<EndpointsSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<EndpointsSettingsData>({
|
||||
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() {
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.endpoints.management', 'Endpoint Management')}</Text>
|
||||
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}
|
||||
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
|
||||
value={settings.toRemove || []}
|
||||
onChange={(value) => setSettings({ ...settings, toRemove: value })}
|
||||
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select endpoints to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
<Group gap="xs" align="flex-start">
|
||||
<MultiSelect
|
||||
label={t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}
|
||||
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
|
||||
value={settings.toRemove || []}
|
||||
onChange={(value) => setSettings({ ...settings, toRemove: value })}
|
||||
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select endpoints to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('toRemove')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}
|
||||
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
|
||||
value={settings.groupsToRemove || []}
|
||||
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
|
||||
data={commonGroups.map(group => ({ value: group, label: group }))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select groups to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
<Group gap="xs" align="flex-start">
|
||||
<MultiSelect
|
||||
label={t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}
|
||||
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
|
||||
value={settings.groupsToRemove || []}
|
||||
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
|
||||
data={commonGroups.map(group => ({ value: group, label: group }))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select groups to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('groupsToRemove')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Paper bg="var(--mantine-color-blue-light)" p="sm" radius="sm">
|
||||
|
||||
@ -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<FeaturesSettingsData>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<FeaturesSettingsData>({
|
||||
sectionName: 'features',
|
||||
fetchTransformer: async () => {
|
||||
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
const 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<string, any> = {};
|
||||
|
||||
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')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.enabled ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.enabled ?? true}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.features.serverCertificate.organizationName', 'Organization Name')}
|
||||
description={t('admin.settings.features.serverCertificate.organizationName.description', 'Organization name for generated certificates')}
|
||||
value={settings.serverCertificate?.organizationName || 'Stirling-PDF'}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
|
||||
})}
|
||||
placeholder="Stirling-PDF"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.features.serverCertificate.organizationName', 'Organization Name')}
|
||||
description={t('admin.settings.features.serverCertificate.organizationName.description', 'Organization name for generated certificates')}
|
||||
value={settings.serverCertificate?.organizationName || 'Stirling-PDF'}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
|
||||
})}
|
||||
placeholder="Stirling-PDF"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.organizationName')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.features.serverCertificate.validity', 'Certificate Validity (days)')}
|
||||
description={t('admin.settings.features.serverCertificate.validity.description', 'Number of days the certificate will be valid')}
|
||||
value={settings.serverCertificate?.validity ?? 365}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, validity: Number(value) }
|
||||
})}
|
||||
min={1}
|
||||
max={3650}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<NumberInput
|
||||
label={t('admin.settings.features.serverCertificate.validity', 'Certificate Validity (days)')}
|
||||
description={t('admin.settings.features.serverCertificate.validity.description', 'Number of days the certificate will be valid')}
|
||||
value={settings.serverCertificate?.validity ?? 365}
|
||||
onChange={(value) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, validity: Number(value) }
|
||||
})}
|
||||
min={1}
|
||||
max={3650}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.validity')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@ -164,13 +175,16 @@ export default function AdminFeaturesSection() {
|
||||
{t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@ -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<GeneralSettingsData>({
|
||||
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<GeneralSettingsData>({
|
||||
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<string, any> = {
|
||||
// UI settings
|
||||
'ui.appNameNavbar': settings.ui.appNameNavbar,
|
||||
'ui.languages': settings.ui.languages,
|
||||
// System settings
|
||||
'system.defaultLocale': settings.system.defaultLocale,
|
||||
'system.showUpdate': settings.system.showUpdate,
|
||||
'system.showUpdateOnlyAdmin': settings.system.showUpdateOnlyAdmin,
|
||||
'system.customHTMLFiles': settings.system.customHTMLFiles,
|
||||
'system.fileUploadLimit': settings.system.fileUploadLimit,
|
||||
// Premium custom metadata
|
||||
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
|
||||
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
|
||||
'premium.proFeatures.customMetadata.creator': settings.customMetadata?.creator,
|
||||
'premium.proFeatures.customMetadata.producer': settings.customMetadata?.producer
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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<LegalSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<LegalSettingsData>({
|
||||
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() {
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}
|
||||
description={t('admin.settings.legal.termsAndConditions.description', 'URL or filename to terms and conditions')}
|
||||
value={settings.termsAndConditions || ''}
|
||||
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
|
||||
placeholder="https://example.com/terms"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}
|
||||
description={t('admin.settings.legal.termsAndConditions.description', 'URL or filename to terms and conditions')}
|
||||
value={settings.termsAndConditions || ''}
|
||||
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
|
||||
placeholder="https://example.com/terms"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('termsAndConditions')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}
|
||||
description={t('admin.settings.legal.privacyPolicy.description', 'URL or filename to privacy policy')}
|
||||
value={settings.privacyPolicy || ''}
|
||||
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
|
||||
placeholder="https://example.com/privacy"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}
|
||||
description={t('admin.settings.legal.privacyPolicy.description', 'URL or filename to privacy policy')}
|
||||
value={settings.privacyPolicy || ''}
|
||||
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
|
||||
placeholder="https://example.com/privacy"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('privacyPolicy')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}
|
||||
description={t('admin.settings.legal.accessibilityStatement.description', 'URL or filename to accessibility statement')}
|
||||
value={settings.accessibilityStatement || ''}
|
||||
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
|
||||
placeholder="https://example.com/accessibility"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}
|
||||
description={t('admin.settings.legal.accessibilityStatement.description', 'URL or filename to accessibility statement')}
|
||||
value={settings.accessibilityStatement || ''}
|
||||
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
|
||||
placeholder="https://example.com/accessibility"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('accessibilityStatement')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}
|
||||
description={t('admin.settings.legal.cookiePolicy.description', 'URL or filename to cookie policy')}
|
||||
value={settings.cookiePolicy || ''}
|
||||
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
|
||||
placeholder="https://example.com/cookies"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}
|
||||
description={t('admin.settings.legal.cookiePolicy.description', 'URL or filename to cookie policy')}
|
||||
value={settings.cookiePolicy || ''}
|
||||
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
|
||||
placeholder="https://example.com/cookies"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('cookiePolicy')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.impressum', 'Impressum')}
|
||||
description={t('admin.settings.legal.impressum.description', 'URL or filename to impressum (required in some jurisdictions)')}
|
||||
value={settings.impressum || ''}
|
||||
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
|
||||
placeholder="https://example.com/impressum"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.legal.impressum', 'Impressum')}
|
||||
description={t('admin.settings.legal.impressum.description', 'URL or filename to impressum (required in some jurisdictions)')}
|
||||
value={settings.impressum || ''}
|
||||
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
|
||||
placeholder="https://example.com/impressum"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('impressum')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@ -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<MailSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<MailSettingsData>({
|
||||
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')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.mail.host', 'SMTP Host')}
|
||||
description={t('admin.settings.mail.host.description', 'SMTP server hostname')}
|
||||
value={settings.host || ''}
|
||||
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.mail.host', 'SMTP Host')}
|
||||
description={t('admin.settings.mail.host.description', 'SMTP server hostname')}
|
||||
value={settings.host || ''}
|
||||
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
|
||||
placeholder="smtp.example.com"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('host')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={t('admin.settings.mail.port', 'SMTP Port')}
|
||||
description={t('admin.settings.mail.port.description', 'SMTP server port (typically 587 for TLS, 465 for SSL)')}
|
||||
value={settings.port || 587}
|
||||
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<NumberInput
|
||||
label={t('admin.settings.mail.port', 'SMTP Port')}
|
||||
description={t('admin.settings.mail.port.description', 'SMTP server port (typically 587 for TLS, 465 for SSL)')}
|
||||
value={settings.port || 587}
|
||||
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('port')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.mail.username', 'SMTP Username')}
|
||||
description={t('admin.settings.mail.username.description', 'SMTP authentication username')}
|
||||
value={settings.username || ''}
|
||||
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.mail.username', 'SMTP Username')}
|
||||
description={t('admin.settings.mail.username.description', 'SMTP authentication username')}
|
||||
value={settings.username || ''}
|
||||
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('username')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
label={t('admin.settings.mail.password', 'SMTP Password')}
|
||||
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<PasswordInput
|
||||
label={t('admin.settings.mail.password', 'SMTP Password')}
|
||||
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('password')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.mail.from', 'From Address')}
|
||||
description={t('admin.settings.mail.from.description', 'Email address to use as sender')}
|
||||
value={settings.from || ''}
|
||||
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.mail.from', 'From Address')}
|
||||
description={t('admin.settings.mail.from.description', 'Email address to use as sender')}
|
||||
value={settings.from || ''}
|
||||
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
|
||||
placeholder="noreply@example.com"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('from')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@ -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<PremiumSettingsData>({});
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<PremiumSettingsData>({
|
||||
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() {
|
||||
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License Configuration')}</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={t('admin.settings.premium.key', 'License Key')}
|
||||
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key')}
|
||||
value={settings.key || ''}
|
||||
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
/>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('admin.settings.premium.key', 'License Key')}
|
||||
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key')}
|
||||
value={settings.key || ''}
|
||||
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('key')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@ -123,10 +107,13 @@ export default function AdminPremiumSection() {
|
||||
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@ -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<PrivacySettingsData>({});
|
||||
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<PrivacySettingsData>({
|
||||
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')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableAnalytics || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableAnalytics || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('enableAnalytics')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@ -126,10 +137,13 @@ export default function AdminPrivacySection() {
|
||||
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.metricsEnabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.metricsEnabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('metricsEnabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@ -146,10 +160,13 @@ export default function AdminPrivacySection() {
|
||||
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.googleVisibility || false}
|
||||
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.googleVisibility || false}
|
||||
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('googleVisibility')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@ -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<SecuritySettingsData>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<SecuritySettingsData>({
|
||||
sectionName: 'security',
|
||||
fetchTransformer: async () => {
|
||||
const [securityResponse, premiumResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get('/api/v1/admin/settings/section/security'),
|
||||
apiClient.get('/api/v1/admin/settings/section/premium'),
|
||||
apiClient.get('/api/v1/admin/settings/section/system')
|
||||
]);
|
||||
|
||||
const 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<string, any> = {
|
||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||
'premium.enterpriseFeatures.audit.level': audit?.level,
|
||||
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
|
||||
};
|
||||
|
||||
// Save HTML URL security settings via delta endpoint
|
||||
if (html?.urlSecurity) {
|
||||
deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled;
|
||||
deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level;
|
||||
@ -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')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableLogin || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableLogin || false}
|
||||
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending(rawSettings, 'enableLogin')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -199,6 +208,11 @@ export default function AdminSecuritySection() {
|
||||
]}
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
/>
|
||||
{isFieldPending(rawSettings, 'loginMethod') && (
|
||||
<Group mt="xs">
|
||||
<PendingBadge show={true} />
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -230,10 +244,13 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.csrfDisabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.csrfDisabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending(rawSettings, 'csrfDisabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@ -262,10 +279,13 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.jwt?.persistence || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.persistence || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending(rawSettings, 'jwt.persistence')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@ -312,10 +332,13 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.jwt?.secureCookie || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.jwt?.secureCookie || false}
|
||||
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending(rawSettings, 'jwt.secureCookie')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@ -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')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.audit?.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.audit?.enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending(rawSettings, 'audit.enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -382,16 +408,19 @@ export default function AdminSecuritySection() {
|
||||
{t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.enabled || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.html?.urlSecurity?.enabled || false}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
html: {
|
||||
...settings.html,
|
||||
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending(rawSettings, 'html.urlSecurity.enabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
191
frontend/src/hooks/useAdminSettings.ts
Normal file
191
frontend/src/hooks/useAdminSettings.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { useState } from 'react';
|
||||
import apiClient from '../services/apiClient';
|
||||
import { mergePendingSettings, isFieldPending, hasPendingChanges, getCurrentValue } from '../utils/settingsPendingHelper';
|
||||
|
||||
interface UseAdminSettingsOptions<T> {
|
||||
sectionName: string;
|
||||
/**
|
||||
* Optional transformer to combine data from multiple endpoints.
|
||||
* If not provided, uses the section response directly.
|
||||
*/
|
||||
fetchTransformer?: () => Promise<T>;
|
||||
/**
|
||||
* Optional transformer to split settings before saving.
|
||||
* Returns an object with sectionData and optionally deltaSettings.
|
||||
*/
|
||||
saveTransformer?: (settings: T) => {
|
||||
sectionData: any;
|
||||
deltaSettings?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAdminSettingsReturn<T> {
|
||||
settings: T;
|
||||
rawSettings: any;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
setSettings: (settings: T) => void;
|
||||
fetchSettings: () => Promise<void>;
|
||||
saveSettings: () => Promise<void>;
|
||||
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<T = any>(
|
||||
options: UseAdminSettingsOptions<T>
|
||||
): UseAdminSettingsReturn<T> {
|
||||
const { sectionName, fetchTransformer, saveTransformer } = options;
|
||||
|
||||
const [settings, setSettings] = useState<T>({} as T);
|
||||
const [rawSettings, setRawSettings] = useState<any>(null);
|
||||
const [originalSettings, setOriginalSettings] = useState<T>({} 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
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
166
frontend/src/utils/settingsPendingHelper.ts
Normal file
166
frontend/src/utils/settingsPendingHelper.ts
Normal file
@ -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<T = any> {
|
||||
_pending?: Partial<T>;
|
||||
[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<T extends SettingsWithPending>(settings: T): Omit<T, '_pending'> {
|
||||
if (!settings || !settings._pending) {
|
||||
// No pending changes, return as-is (without _pending property)
|
||||
const { _pending, ...rest } = settings || {};
|
||||
return rest as Omit<T, '_pending'>;
|
||||
}
|
||||
|
||||
// Deep merge pending changes
|
||||
const merged = deepMerge(settings, settings._pending);
|
||||
|
||||
// Remove _pending from result
|
||||
const { _pending, ...result } = merged;
|
||||
return result as Omit<T, '_pending'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T extends SettingsWithPending>(
|
||||
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<T extends SettingsWithPending>(
|
||||
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<T extends SettingsWithPending>(
|
||||
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<T extends SettingsWithPending>(
|
||||
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
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user