This commit is contained in:
Anthony Stirling 2025-10-24 19:27:37 +01:00
parent 928a591839
commit e4db9e183d
26 changed files with 1528 additions and 867 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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