settingsPage Init selfhost (#4734)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Anthony Stirling
2025-10-28 14:47:41 +00:00
committed by GitHub
co-authored by James Brunton
parent d2b38ef4b8
commit d0c5d74471
68 changed files with 9133 additions and 282 deletions
@@ -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")
@@ -490,21 +490,9 @@ public class ApplicationProperties {
@Data
public static class Ui {
private String appName;
private String homeDescription;
private String appNameNavbar;
private List<String> languages;
public String getAppName() {
return appName != null && appName.trim().length() > 0 ? appName : null;
}
public String getHomeDescription() {
return homeDescription != null && homeDescription.trim().length() > 0
? homeDescription
: null;
}
public String getAppNameNavbar() {
return appNameNavbar != null && appNameNavbar.trim().length() > 0
? appNameNavbar
@@ -560,6 +548,7 @@ public class ApplicationProperties {
@Data
public static class Mail {
private boolean enabled;
private boolean enableInvites = false;
private String host;
private int port;
private String username;
@@ -335,11 +335,6 @@ public class PostHogService {
applicationProperties.getSystem().isScarfEnabled());
// Capture UI properties
addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName());
addIfNotEmpty(
properties,
"ui_homeDescription",
applicationProperties.getUi().getHomeDescription());
addIfNotEmpty(
properties, "ui_appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
@@ -6,4 +6,6 @@ public interface UserServiceInterface {
String getCurrentUsername();
long getTotalUsersCount();
boolean isCurrentUserAdmin();
}
@@ -0,0 +1,29 @@
package stirling.software.common.util;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/**
* Captures application command-line arguments at startup so they can be reused for restart
* operations. This allows the application to restart with the same configuration.
*/
@Slf4j
@Component
public class AppArgsCapture implements ApplicationRunner {
public static final AtomicReference<List<String>> APP_ARGS = new AtomicReference<>(List.of());
@Override
public void run(ApplicationArguments args) {
APP_ARGS.set(List.of(args.getSourceArgs()));
log.debug(
"Captured {} application arguments for restart capability",
args.getSourceArgs().length);
}
}
@@ -0,0 +1,84 @@
package stirling.software.common.util;
import java.io.File;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
/** Utility class to locate JAR files at runtime for restart operations */
@Slf4j
public class JarPathUtil {
/**
* Gets the path to the currently running JAR file
*
* @return Path to the current JAR, or null if not running from a JAR
*/
public static Path currentJar() {
try {
Path jar =
Paths.get(
JarPathUtil.class
.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI())
.toAbsolutePath();
// Check if we're actually running from a JAR (not from IDE/classes directory)
if (jar.toString().endsWith(".jar")) {
log.debug("Current JAR located at: {}", jar);
return jar;
} else {
log.warn("Not running from JAR, current location: {}", jar);
return null;
}
} catch (URISyntaxException e) {
log.error("Failed to determine current JAR location", e);
return null;
}
}
/**
* 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
*/
public static Path restartHelperJar() {
Path appJar = currentJar();
if (appJar == null) {
return null;
}
Path helperJar = appJar.getParent().resolve("restart-helper.jar");
if (Files.isRegularFile(helperJar)) {
log.debug("Restart helper JAR located at: {}", helperJar);
return helperJar;
} else {
log.warn("Restart helper JAR not found at: {}", helperJar);
return null;
}
}
/**
* Gets the java binary path for the current JVM
*
* @return Path to java executable
*/
public static String javaExecutable() {
String javaHome = System.getProperty("java.home");
String javaBin = javaHome + File.separator + "bin" + File.separator + "java";
// On Windows, add .exe extension
if (System.getProperty("os.name").toLowerCase().contains("win")) {
javaBin += ".exe";
}
return javaBin;
}
}
@@ -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());
}
@@ -1,11 +1,14 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
@@ -46,4 +49,392 @@ public class SettingsController {
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
}
// ========== GENERAL SETTINGS ==========
@GetMapping("/admin/settings/general")
@Hidden
public ResponseEntity<Map<String, Object>> getGeneralSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui", applicationProperties.getUi());
settings.put(
"system",
Map.of(
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
"showUpdateOnlyAdmin",
applicationProperties.getSystem().getShowUpdateOnlyAdmin(),
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/general")
@Hidden
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings)
throws IOException {
// Update UI settings
if (settings.containsKey("ui")) {
Map<String, String> ui = (Map<String, String>) settings.get("ui");
if (ui.containsKey("appNameNavbar")) {
GeneralUtils.saveKeyToSettings("ui.appNameNavbar", ui.get("appNameNavbar"));
applicationProperties.getUi().setAppNameNavbar(ui.get("appNameNavbar"));
}
}
// Update System settings
if (settings.containsKey("system")) {
Map<String, Object> system = (Map<String, Object>) settings.get("system");
if (system.containsKey("defaultLocale")) {
GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale"));
applicationProperties
.getSystem()
.setDefaultLocale((String) system.get("defaultLocale"));
}
if (system.containsKey("showUpdate")) {
GeneralUtils.saveKeyToSettings("system.showUpdate", system.get("showUpdate"));
applicationProperties.getSystem().setShowUpdate((Boolean) system.get("showUpdate"));
}
if (system.containsKey("showUpdateOnlyAdmin")) {
GeneralUtils.saveKeyToSettings(
"system.showUpdateOnlyAdmin", system.get("showUpdateOnlyAdmin"));
applicationProperties
.getSystem()
.setShowUpdateOnlyAdmin((Boolean) system.get("showUpdateOnlyAdmin"));
}
if (system.containsKey("fileUploadLimit")) {
GeneralUtils.saveKeyToSettings(
"system.fileUploadLimit", system.get("fileUploadLimit"));
applicationProperties
.getSystem()
.setFileUploadLimit((String) system.get("fileUploadLimit"));
}
}
return ResponseEntity.ok(
"General settings updated. Restart required for changes to take effect.");
}
// ========== SECURITY SETTINGS ==========
@GetMapping("/admin/settings/security")
@Hidden
public ResponseEntity<Map<String, Object>> getSecuritySettings() {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("csrfDisabled", security.getCsrfDisabled());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
settings.put(
"initialLogin",
Map.of(
"username",
security.getInitialLogin().getUsername() != null
? security.getInitialLogin().getUsername()
: ""));
// JWT settings
ApplicationProperties.Security.Jwt jwt = security.getJwt();
settings.put(
"jwt",
Map.of(
"enableKeystore", jwt.isEnableKeystore(),
"enableKeyRotation", jwt.isEnableKeyRotation(),
"enableKeyCleanup", jwt.isEnableKeyCleanup(),
"keyRetentionDays", jwt.getKeyRetentionDays()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/security")
@Hidden
public ResponseEntity<String> updateSecuritySettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableLogin")) {
GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin"));
applicationProperties
.getSecurity()
.setEnableLogin((Boolean) settings.get("enableLogin"));
}
if (settings.containsKey("csrfDisabled")) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
applicationProperties
.getSecurity()
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
}
if (settings.containsKey("loginMethod")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties
.getSecurity()
.setLoginMethod((String) settings.get("loginMethod"));
}
if (settings.containsKey("loginAttemptCount")) {
GeneralUtils.saveKeyToSettings(
"security.loginAttemptCount", settings.get("loginAttemptCount"));
applicationProperties
.getSecurity()
.setLoginAttemptCount((Integer) settings.get("loginAttemptCount"));
}
if (settings.containsKey("loginResetTimeMinutes")) {
GeneralUtils.saveKeyToSettings(
"security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes"));
applicationProperties
.getSecurity()
.setLoginResetTimeMinutes(
((Number) settings.get("loginResetTimeMinutes")).longValue());
}
// JWT settings
if (settings.containsKey("jwt")) {
Map<String, Object> jwt = (Map<String, Object>) settings.get("jwt");
if (jwt.containsKey("keyRetentionDays")) {
GeneralUtils.saveKeyToSettings(
"security.jwt.keyRetentionDays", jwt.get("keyRetentionDays"));
applicationProperties
.getSecurity()
.getJwt()
.setKeyRetentionDays((Integer) jwt.get("keyRetentionDays"));
}
}
return ResponseEntity.ok(
"Security settings updated. Restart required for changes to take effect.");
}
// ========== CONNECTIONS SETTINGS (OAuth/SAML) ==========
@GetMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<Map<String, Object>> getConnectionsSettings() {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
// OAuth2 settings
ApplicationProperties.Security.OAUTH2 oauth2 = security.getOauth2();
settings.put(
"oauth2",
Map.of(
"enabled", oauth2.getEnabled(),
"issuer", oauth2.getIssuer() != null ? oauth2.getIssuer() : "",
"clientId", oauth2.getClientId() != null ? oauth2.getClientId() : "",
"provider", oauth2.getProvider() != null ? oauth2.getProvider() : "",
"autoCreateUser", oauth2.getAutoCreateUser(),
"blockRegistration", oauth2.getBlockRegistration(),
"useAsUsername",
oauth2.getUseAsUsername() != null
? oauth2.getUseAsUsername()
: ""));
// SAML2 settings
ApplicationProperties.Security.SAML2 saml2 = security.getSaml2();
settings.put(
"saml2",
Map.of(
"enabled", saml2.getEnabled(),
"provider", saml2.getProvider() != null ? saml2.getProvider() : "",
"autoCreateUser", saml2.getAutoCreateUser(),
"blockRegistration", saml2.getBlockRegistration(),
"registrationId", saml2.getRegistrationId()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<String> updateConnectionsSettings(
@RequestBody Map<String, Object> settings) throws IOException {
// OAuth2 settings
if (settings.containsKey("oauth2")) {
Map<String, Object> oauth2 = (Map<String, Object>) settings.get("oauth2");
if (oauth2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled"));
applicationProperties
.getSecurity()
.getOauth2()
.setEnabled((Boolean) oauth2.get("enabled"));
}
if (oauth2.containsKey("issuer")) {
GeneralUtils.saveKeyToSettings("security.oauth2.issuer", oauth2.get("issuer"));
applicationProperties
.getSecurity()
.getOauth2()
.setIssuer((String) oauth2.get("issuer"));
}
if (oauth2.containsKey("clientId")) {
GeneralUtils.saveKeyToSettings("security.oauth2.clientId", oauth2.get("clientId"));
applicationProperties
.getSecurity()
.getOauth2()
.setClientId((String) oauth2.get("clientId"));
}
if (oauth2.containsKey("clientSecret")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.clientSecret", oauth2.get("clientSecret"));
applicationProperties
.getSecurity()
.getOauth2()
.setClientSecret((String) oauth2.get("clientSecret"));
}
if (oauth2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.oauth2.provider", oauth2.get("provider"));
applicationProperties
.getSecurity()
.getOauth2()
.setProvider((String) oauth2.get("provider"));
}
if (oauth2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.autoCreateUser", oauth2.get("autoCreateUser"));
applicationProperties
.getSecurity()
.getOauth2()
.setAutoCreateUser((Boolean) oauth2.get("autoCreateUser"));
}
if (oauth2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.blockRegistration", oauth2.get("blockRegistration"));
applicationProperties
.getSecurity()
.getOauth2()
.setBlockRegistration((Boolean) oauth2.get("blockRegistration"));
}
if (oauth2.containsKey("useAsUsername")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.useAsUsername", oauth2.get("useAsUsername"));
applicationProperties
.getSecurity()
.getOauth2()
.setUseAsUsername((String) oauth2.get("useAsUsername"));
}
}
// SAML2 settings
if (settings.containsKey("saml2")) {
Map<String, Object> saml2 = (Map<String, Object>) settings.get("saml2");
if (saml2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled"));
applicationProperties
.getSecurity()
.getSaml2()
.setEnabled((Boolean) saml2.get("enabled"));
}
if (saml2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.saml2.provider", saml2.get("provider"));
applicationProperties
.getSecurity()
.getSaml2()
.setProvider((String) saml2.get("provider"));
}
if (saml2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings(
"security.saml2.autoCreateUser", saml2.get("autoCreateUser"));
applicationProperties
.getSecurity()
.getSaml2()
.setAutoCreateUser((Boolean) saml2.get("autoCreateUser"));
}
if (saml2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings(
"security.saml2.blockRegistration", saml2.get("blockRegistration"));
applicationProperties
.getSecurity()
.getSaml2()
.setBlockRegistration((Boolean) saml2.get("blockRegistration"));
}
}
return ResponseEntity.ok(
"Connection settings updated. Restart required for changes to take effect.");
}
// ========== PRIVACY SETTINGS ==========
@GetMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<Map<String, Object>> getPrivacySettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
settings.put("googleVisibility", applicationProperties.getSystem().getGooglevisibility());
settings.put("metricsEnabled", applicationProperties.getMetrics().getEnabled());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<String> updatePrivacySettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableAnalytics")) {
GeneralUtils.saveKeyToSettings(
"system.enableAnalytics", settings.get("enableAnalytics"));
applicationProperties
.getSystem()
.setEnableAnalytics((Boolean) settings.get("enableAnalytics"));
}
if (settings.containsKey("googleVisibility")) {
GeneralUtils.saveKeyToSettings(
"system.googlevisibility", settings.get("googleVisibility"));
applicationProperties
.getSystem()
.setGooglevisibility((Boolean) settings.get("googleVisibility"));
}
if (settings.containsKey("metricsEnabled")) {
GeneralUtils.saveKeyToSettings("metrics.enabled", settings.get("metricsEnabled"));
applicationProperties.getMetrics().setEnabled((Boolean) settings.get("metricsEnabled"));
}
return ResponseEntity.ok(
"Privacy settings updated. Restart required for changes to take effect.");
}
// ========== ADVANCED SETTINGS ==========
@GetMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<Map<String, Object>> getAdvancedSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("endpoints", applicationProperties.getEndpoints());
settings.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
settings.put("enableUrlToPDF", applicationProperties.getSystem().getEnableUrlToPDF());
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
settings.put(
"tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableAlphaFunctionality")) {
GeneralUtils.saveKeyToSettings(
"system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality"));
applicationProperties
.getSystem()
.setEnableAlphaFunctionality(
(Boolean) settings.get("enableAlphaFunctionality"));
}
if (settings.containsKey("maxDPI")) {
GeneralUtils.saveKeyToSettings("system.maxDPI", settings.get("maxDPI"));
applicationProperties.getSystem().setMaxDPI((Integer) settings.get("maxDPI"));
}
if (settings.containsKey("enableUrlToPDF")) {
GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF"));
applicationProperties
.getSystem()
.setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF"));
}
return ResponseEntity.ok(
"Advanced settings updated. Restart required for changes to take effect.");
}
}
@@ -15,6 +15,7 @@ import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@@ -24,17 +25,21 @@ public class ConfigController {
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
public ConfigController(
ApplicationProperties applicationProperties,
ApplicationContext applicationContext,
EndpointConfiguration endpointConfiguration,
@org.springframework.beans.factory.annotation.Autowired(required = false)
ServerCertificateServiceInterface serverCertificateService) {
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
}
@GetMapping("/app-config")
@@ -51,14 +56,26 @@ public class ConfigController {
configData.put("serverPort", appConfig.getServerPort());
// Extract values from ApplicationProperties
configData.put("appName", applicationProperties.getUi().getAppName());
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("homeDescription", applicationProperties.getUi().getHomeDescription());
configData.put("languages", applicationProperties.getUi().getLanguages());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// Mail settings
configData.put("enableEmailInvites", applicationProperties.getMail().isEnableInvites());
// Check if user is admin using UserServiceInterface
boolean isAdmin = false;
if (userService != null) {
try {
isAdmin = userService.isCurrentUserAdmin();
} catch (Exception e) {
// If there's an error, isAdmin remains false
}
}
configData.put("isAdmin", isAdmin);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -99,6 +99,7 @@ premium:
mail:
enabled: false # set to 'true' to enable sending emails
enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin)
host: smtp.example.com # SMTP server hostname
port: 587 # SMTP server port
username: '' # SMTP server username
@@ -171,8 +172,6 @@ system:
cleanupSystemTemp: false # Whether to clean broader system temp directory
ui:
appName: '' # application's visible name
homeDescription: '' # short description or tagline shown on the homepage
appNameNavbar: '' # name displayed on the navigation bar
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
@@ -53,7 +53,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@ProprietaryUiDataApi
@EnterpriseEndpoint
public class ProprietaryUIDataController {
private final ApplicationProperties applicationProperties;
@@ -89,6 +88,7 @@ public class ProprietaryUIDataController {
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint
@Operation(summary = "Get audit dashboard data")
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
AuditDashboardData data = new AuditDashboardData();
@@ -39,7 +39,6 @@ import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.JwtAuthenticationEntryPoint;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.filter.FirstLoginFilter;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
@@ -74,7 +73,6 @@ public class SecurityConfiguration {
private final JwtServiceInterface jwtService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final LoginAttemptService loginAttemptService;
private final FirstLoginFilter firstLoginFilter;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
@@ -93,7 +91,6 @@ public class SecurityConfiguration {
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
FirstLoginFilter firstLoginFilter,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
@@ -110,7 +107,6 @@ public class SecurityConfiguration {
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.loginAttemptService = loginAttemptService;
this.firstLoginFilter = firstLoginFilter;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
@@ -135,8 +131,7 @@ public class SecurityConfiguration {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class);
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
if (v2Enabled) {
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
@@ -1,19 +1,27 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
@@ -32,7 +40,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.AdminApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.AppArgsCapture;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.JarPathUtil;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
@@ -45,6 +55,7 @@ public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
@@ -195,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(
@@ -206,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) {
@@ -217,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)
@@ -388,6 +418,101 @@ public class AdminSettingsController {
}
}
@PostMapping("/restart")
@Operation(
summary = "Restart the application",
description =
"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 = "403",
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to initiate restart")
})
public ResponseEntity<String> restartApplication() {
try {
log.warn("Admin initiated application restart");
// Get paths to current JAR and restart helper
Path appJar = JarPathUtil.currentJar();
Path helperJar = JarPathUtil.restartHelperJar();
if (appJar == null) {
log.error("Cannot restart: not running from JAR (likely development mode)");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
"Restart not available in development mode. Please restart the application manually.");
}
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.");
}
// Get current application arguments
List<String> appArgs = AppArgsCapture.APP_ARGS.get();
// Write args to temp file to avoid command-line quoting issues
Path argsFile = Files.createTempFile("stirling-app-args-", ".txt");
Files.write(argsFile, appArgs, StandardCharsets.UTF_8);
// Get current process PID and java executable
long pid = ProcessHandle.current().pid();
String javaBin = JarPathUtil.javaExecutable();
// Build command to launch restart helper
List<String> cmd = new ArrayList<>();
cmd.add(javaBin);
cmd.add("-jar");
cmd.add(helperJar.toString());
cmd.add("--pid");
cmd.add(Long.toString(pid));
cmd.add("--app");
cmd.add(appJar.toString());
cmd.add("--argsFile");
cmd.add(argsFile.toString());
cmd.add("--backoffMs");
cmd.add("1000");
log.info("Launching restart helper: {}", String.join(" ", cmd));
// Launch restart helper process
new ProcessBuilder(cmd)
.directory(appJar.getParent().toFile())
.inheritIO() // Forward logs
.start();
// Clear pending changes since we're restarting
pendingChanges.clear();
// Give the HTTP response time to complete, then exit
new Thread(
() -> {
try {
Thread.sleep(1000);
log.info("Shutting down for restart...");
SpringApplication.exit(applicationContext, () -> 0);
System.exit(0);
} catch (InterruptedException e) {
log.error("Restart interrupted: {}", e.getMessage(), e);
Thread.currentThread().interrupt();
}
})
.start();
return ResponseEntity.ok(
"Application restart initiated. The server will be back online shortly.");
} catch (Exception e) {
log.error("Failed to initiate restart: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to initiate application restart: " + e.getMessage());
}
}
private Object getSectionData(String sectionName) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
@@ -626,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);
}
}
@@ -2,21 +2,19 @@ package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.eclipse.jetty.http.HttpStatus;
import org.springframework.context.annotation.Conditional;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
@@ -42,15 +40,19 @@ public class DatabaseController {
summary = "Import a database backup file",
description = "Uploads and imports a database backup SQL file.")
@PostMapping(consumes = "multipart/form-data", value = "import-database")
public String importDatabase(
public ResponseEntity<?> importDatabase(
@Parameter(description = "SQL file to import", required = true)
@RequestParam("fileInput")
MultipartFile file,
RedirectAttributes redirectAttributes)
MultipartFile file)
throws IOException {
if (file == null || file.isEmpty()) {
redirectAttributes.addAttribute("error", "fileNullOrEmpty");
return "redirect:/database";
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
java.util.Map.of(
"error",
"fileNullOrEmpty",
"message",
"File is null or empty"));
}
log.info("Received file: {}", file.getOriginalFilename());
Path tempTemplatePath = Files.createTempFile("backup_", ".sql");
@@ -58,15 +60,31 @@ public class DatabaseController {
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath);
if (importSuccess) {
redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed");
return ResponseEntity.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database imported successfully"));
} else {
redirectAttributes.addAttribute("error", "failedImportFile");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database file"));
}
} catch (Exception e) {
log.error("Error importing database: {}", e.getMessage());
redirectAttributes.addAttribute("error", "failedImportFile");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database: " + e.getMessage()));
}
return "redirect:/database";
}
@Hidden
@@ -74,11 +92,17 @@ public class DatabaseController {
summary = "Import database backup by filename",
description = "Imports a database backup file from the server using its file name.")
@GetMapping("/import-database-file/{fileName}")
public String importDatabaseFromBackupUI(
public ResponseEntity<?> importDatabaseFromBackupUI(
@Parameter(description = "Name of the file to import", required = true) @PathVariable
String fileName) {
if (fileName == null || fileName.isEmpty()) {
return "redirect:/database?error=fileNullOrEmpty";
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
java.util.Map.of(
"error",
"fileNullOrEmpty",
"message",
"File name is null or empty"));
}
// Check if the file exists in the backup list
boolean fileExists =
@@ -86,14 +110,31 @@ public class DatabaseController {
.anyMatch(backup -> backup.getFileName().equals(fileName));
if (!fileExists) {
log.error("File {} not found in backup list", fileName);
return "redirect:/database?error=fileNotFound";
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(
java.util.Map.of(
"error",
"fileNotFound",
"message",
"File not found in backup list"));
}
log.info("Received file: {}", fileName);
if (databaseService.importDatabaseFromUI(fileName)) {
log.info("File {} imported to database", fileName);
return "redirect:/database?infoMessage=importIntoDatabaseSuccessed";
return ResponseEntity.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database backup imported successfully"));
}
return "redirect:/database?error=failedImportFile";
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database file"));
}
@Hidden
@@ -101,24 +142,42 @@ public class DatabaseController {
summary = "Delete a database backup file",
description = "Deletes a specified database backup file from the server.")
@GetMapping("/delete/{fileName}")
public String deleteFile(
public ResponseEntity<?> deleteFile(
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
String fileName) {
if (fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("File must not be null or empty");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
java.util.Map.of(
"error",
"invalidFileName",
"message",
"File must not be null or empty"));
}
try {
if (databaseService.deleteBackupFile(fileName)) {
log.info("Deleted file: {}", fileName);
return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully"));
} else {
log.error("Failed to delete file: {}", fileName);
return "redirect:/database?error=failedToDeleteFile";
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedToDeleteFile",
"message",
"Failed to delete backup file"));
}
} catch (IOException e) {
log.error("Error deleting file: {}", e.getMessage());
return "redirect:/database?error=" + e.getMessage();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"deleteError",
"message",
"Error deleting file: " + e.getMessage()));
}
return "redirect:/database";
}
@Hidden
@@ -142,22 +201,29 @@ public class DatabaseController {
.body(resource);
} catch (IOException e) {
log.error("Error downloading file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.SEE_OTHER_303)
.location(URI.create("/database?error=downloadFailed"))
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"downloadFailed",
"message",
"Failed to download file: " + e.getMessage()));
}
}
@Operation(
summary = "Create a database backup",
description =
"This endpoint triggers the creation of a database backup and redirects to the"
+ " database management page.")
description = "This endpoint triggers the creation of a database backup.")
@GetMapping("/createDatabaseBackup")
public String createDatabaseBackup() {
public ResponseEntity<?> createDatabaseBackup() {
log.info("Starting database backup creation...");
databaseService.exportDatabase();
log.info("Database backup successfully created.");
return "redirect:/database?infoMessage=backupCreated";
return ResponseEntity.ok(
java.util.Map.of(
"message",
"backupCreated",
"description",
"Database backup created successfully"));
}
}
@@ -1,10 +1,12 @@
package stirling.software.proprietary.security.controller.api;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
import jakarta.transaction.Transactional;
@@ -30,98 +32,113 @@ public class TeamController {
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/create")
public RedirectView createTeam(@RequestParam("name") String name) {
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
if (teamRepository.existsByNameIgnoreCase(name)) {
return new RedirectView("/teams?messageType=teamExists");
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Team name already exists."));
}
Team team = new Team();
team.setName(name);
teamRepository.save(team);
return new RedirectView("/teams?messageType=teamCreated");
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/rename")
public RedirectView renameTeam(
public ResponseEntity<?> renameTeam(
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
Optional<Team> existing = teamRepository.findById(teamId);
if (existing.isEmpty()) {
return new RedirectView("/teams?messageType=teamNotFound");
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
}
if (teamRepository.existsByNameIgnoreCase(newName)) {
return new RedirectView("/teams?messageType=teamNameExists");
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Team name already exists."));
}
Team team = existing.get();
// Prevent renaming the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot rename Internal team."));
}
team.setName(newName);
teamRepository.save(team);
return new RedirectView("/teams?messageType=teamRenamed");
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/delete")
@Transactional
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
return new RedirectView("/teams?messageType=teamNotFound");
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
}
Team team = teamOpt.get();
// Prevent deleting the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot delete Internal team."));
}
long memberCount = userRepository.countByTeam(team);
if (memberCount > 0) {
return new RedirectView("/teams?messageType=teamHasUsers");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Team must be empty before deletion. Please remove all members first."));
}
teamRepository.delete(team);
return new RedirectView("/teams?messageType=teamDeleted");
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/addUser")
@Transactional
public RedirectView addUserToTeam(
public ResponseEntity<?> addUserToTeam(
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
// Find the team
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> new RuntimeException("Team not found"));
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
}
Team team = teamOpt.get();
// Prevent adding users to the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/teams?error=internalTeamNotAccessible");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot add users to Internal team."));
}
// Find the user
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
User user = userOpt.get();
// Check if user is in the Internal team - prevent moving them
if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot move users from Internal team."));
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
// Redirect back to team details page
return new RedirectView("/teams/" + teamId + "?messageType=userAdded");
return ResponseEntity.ok(Map.of("message", "User added to team successfully"));
}
}
@@ -17,8 +17,6 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.servlet.view.RedirectView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -38,6 +36,7 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -53,6 +52,7 @@ public class UserController {
private final ApplicationProperties applicationProperties;
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final Optional<EmailService> emailService;
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
@@ -137,100 +137,130 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-username")
public RedirectView changeUsername(
public ResponseEntity<?> changeUsername(
Principal principal,
@RequestParam(name = "currentPasswordChangeUsername") String currentPassword,
@RequestParam(name = "newUsername") String newUsername,
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes)
HttpServletResponse response)
throws IOException, SQLException, UnsupportedProviderException {
if (!userService.isUsernameValid(newUsername)) {
return new RedirectView("/account?messageType=invalidUsername", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "invalidUsername", "message", "Invalid username format"));
}
if (principal == null) {
return new RedirectView("/account?messageType=notAuthenticated", true);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
}
// The username MUST be unique when renaming
Optional<User> userOpt = userService.findByUsername(principal.getName());
if (userOpt == null || userOpt.isEmpty()) {
return new RedirectView("/account?messageType=userNotFound", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User not found"));
}
User user = userOpt.get();
if (user.getUsername().equals(newUsername)) {
return new RedirectView("/account?messageType=usernameExists", true);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "usernameExists", "message", "Username already in use"));
}
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/account?messageType=incorrectPassword", true);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
}
if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) {
return new RedirectView("/account?messageType=usernameExists", true);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "usernameExists", "message", "Username already exists"));
}
if (newUsername != null && newUsername.length() > 0) {
try {
userService.changeUsername(user, newUsername);
} catch (IllegalArgumentException e) {
return new RedirectView("/account?messageType=invalidUsername", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"invalidUsername",
"message",
"Invalid username format"));
}
}
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
return ResponseEntity.ok(
Map.of(
"message",
"credsUpdated",
"description",
"Username changed successfully. Please log in again."));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password-on-login")
public RedirectView changePasswordOnLogin(
public ResponseEntity<?> changePasswordOnLogin(
Principal principal,
@RequestParam(name = "currentPassword") String currentPassword,
@RequestParam(name = "newPassword") String newPassword,
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes)
HttpServletResponse response)
throws SQLException, UnsupportedProviderException {
if (principal == null) {
return new RedirectView("/change-creds?messageType=notAuthenticated", true);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (userOpt.isEmpty()) {
return new RedirectView("/change-creds?messageType=userNotFound", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User not found"));
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/change-creds?messageType=incorrectPassword", true);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
}
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
return ResponseEntity.ok(
Map.of(
"message",
"credsUpdated",
"description",
"Password changed successfully. Please log in again."));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password")
public RedirectView changePassword(
public ResponseEntity<?> changePassword(
Principal principal,
@RequestParam(name = "currentPassword") String currentPassword,
@RequestParam(name = "newPassword") String newPassword,
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes)
HttpServletResponse response)
throws SQLException, UnsupportedProviderException {
if (principal == null) {
return new RedirectView("/account?messageType=notAuthenticated", true);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (userOpt.isEmpty()) {
return new RedirectView("/account?messageType=userNotFound", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User not found"));
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/account?messageType=incorrectPassword", true);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
}
userService.changePassword(user, newPassword);
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
return ResponseEntity.ok(
Map.of(
"message",
"credsUpdated",
"description",
"Password changed successfully. Please log in again."));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@@ -248,23 +278,23 @@ public class UserController {
* </ul>
* Keys not listed above will be ignored.
* @param principal The currently authenticated user.
* @return A redirect string to the account page after updating the settings.
* @return A ResponseEntity with success or error information.
* @throws SQLException If a database error occurs.
* @throws UnsupportedProviderException If the operation is not supported for the user's
* provider.
*/
public String updateUserSettings(@RequestBody Map<String, String> updates, Principal principal)
public ResponseEntity<?> updateUserSettings(
@RequestBody Map<String, String> updates, Principal principal)
throws SQLException, UnsupportedProviderException {
log.debug("Processed updates: {}", updates);
// Assuming you have a method in userService to update the settings for a user
userService.updateUserSettings(principal.getName(), updates);
// Redirect to a page of your choice after updating
return "redirect:/account";
return ResponseEntity.ok(Map.of("message", "Settings updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/saveUser")
public RedirectView saveUser(
public ResponseEntity<?> saveUser(
@RequestParam(name = "username", required = true) String username,
@RequestParam(name = "password", required = false) String password,
@RequestParam(name = "role") String role,
@@ -274,33 +304,42 @@ public class UserController {
boolean forceChange)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.isUsernameValid(username)) {
return new RedirectView("/adminSettings?messageType=invalidUsername", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Invalid username format. Username must be 3-50 characters."));
}
if (applicationProperties.getPremium().isEnabled()
&& applicationProperties.getPremium().getMaxUsers()
<= userService.getTotalUsersCount()) {
return new RedirectView("/adminSettings?messageType=maxUsersReached", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Maximum number of users reached for your license."));
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
User user = userOpt.get();
if (user.getUsername().equalsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=usernameExists", true);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Username already exists."));
}
}
if (userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=usernameExists", true);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Username already exists."));
}
try {
// Validate the role
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
// If the role is INTERNAL_API_USER, reject the request
return new RedirectView("/adminSettings?messageType=invalidRole", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
}
} catch (IllegalArgumentException e) {
// If the role ID is not valid, redirect with an error message
return new RedirectView("/adminSettings?messageType=invalidRole", true);
// If the role ID is not valid, return error
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified."));
}
// Use teamId if provided, otherwise use default team
@@ -316,28 +355,144 @@ public class UserController {
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
if (selectedTeam != null
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
return new RedirectView(
"/adminSettings?messageType=internalTeamNotAccessible", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team."));
}
}
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
} else {
if (password.isBlank()) {
return new RedirectView("/adminSettings?messageType=invalidPassword", true);
if (password == null || password.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required."));
}
if (password.length() < 6) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password must be at least 6 characters."));
}
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
}
return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user
true);
return ResponseEntity.ok(Map.of("message", "User created successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/inviteUsers")
public ResponseEntity<?> inviteUsers(
@RequestParam(name = "emails", required = true) String emails,
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
@RequestParam(name = "teamId", required = false) Long teamId)
throws SQLException, UnsupportedProviderException {
// Check if email invites are enabled
if (!applicationProperties.getMail().isEnableInvites()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email invites are not enabled"));
}
// Check if email service is available
if (!emailService.isPresent()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
Map.of(
"error",
"Email service is not configured. Please configure SMTP settings."));
}
// Parse comma-separated email addresses
String[] emailArray = emails.split(",");
if (emailArray.length == 0) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "At least one email address is required"));
}
// Check license limits
if (applicationProperties.getPremium().isEnabled()) {
long currentUserCount = userService.getTotalUsersCount();
int maxUsers = applicationProperties.getPremium().getMaxUsers();
long availableSlots = maxUsers - currentUserCount;
if (availableSlots < emailArray.length) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Not enough user slots available. Available: "
+ availableSlots
+ ", Requested: "
+ emailArray.length));
}
}
// Validate role
try {
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role"));
}
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified"));
}
// Determine team
Long effectiveTeamId = teamId;
if (effectiveTeamId == null) {
Team defaultTeam =
teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
if (defaultTeam != null) {
effectiveTeamId = defaultTeam.getId();
}
} else {
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
if (selectedTeam != null
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team"));
}
}
int successCount = 0;
int failureCount = 0;
StringBuilder errors = new StringBuilder();
// Process each email
for (String email : emailArray) {
email = email.trim();
if (email.isEmpty()) {
continue;
}
InviteResult result = processEmailInvite(email, effectiveTeamId, role);
if (result.isSuccess()) {
successCount++;
} else {
failureCount++;
errors.append(result.getErrorMessage()).append("; ");
}
}
Map<String, Object> response = new HashMap<>();
response.put("successCount", successCount);
response.put("failureCount", failureCount);
if (failureCount > 0) {
response.put("errors", errors.toString());
}
if (successCount > 0) {
response.put("message", successCount + " user(s) invited successfully");
return ResponseEntity.ok(response);
} else {
response.put("error", "Failed to invite any users");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeRole")
@Transactional
public RedirectView changeRole(
public ResponseEntity<?> changeRole(
@RequestParam(name = "username") String username,
@RequestParam(name = "role") String role,
@RequestParam(name = "teamId", required = false) Long teamId,
@@ -345,27 +500,32 @@ public class UserController {
throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (!userOpt.isPresent()) {
return new RedirectView("/adminSettings?messageType=userNotFound", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
if (!userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=userNotFound", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
// Get the currently authenticated username
String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot change your own role."));
}
try {
// Validate the role
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
// If the role is INTERNAL_API_USER, reject the request
return new RedirectView("/adminSettings?messageType=invalidRole", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
}
} catch (IllegalArgumentException e) {
// If the role ID is not valid, redirect with an error message
return new RedirectView("/adminSettings?messageType=invalidRole", true);
// If the role ID is not valid, return error
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified."));
}
User user = userOpt.get();
@@ -375,15 +535,15 @@ public class UserController {
if (team != null) {
// Prevent assigning to Internal team
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
return new RedirectView(
"/adminSettings?messageType=internalTeamNotAccessible", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team."));
}
// Prevent moving users from Internal team
if (user.getTeam() != null
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
return new RedirectView(
"/adminSettings?messageType=cannotMoveInternalUsers", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot move users from Internal team."));
}
user.setTeam(team);
@@ -392,30 +552,31 @@ public class UserController {
}
userService.changeRole(user, role);
return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user
true);
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeUserEnabled/{username}")
public RedirectView changeUserEnabled(
public ResponseEntity<?> changeUserEnabled(
@PathVariable("username") String username,
@RequestParam("enabled") boolean enabled,
Authentication authentication)
throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return new RedirectView("/adminSettings?messageType=userNotFound", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
if (!userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=userNotFound", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
// Get the currently authenticated username
String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot disable your own account."));
}
User user = userOpt.get();
userService.changeUserEnabled(user, enabled);
@@ -442,23 +603,24 @@ public class UserController {
}
}
}
return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user
true);
return ResponseEntity.ok(
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/deleteUser/{username}")
public RedirectView deleteUser(
public ResponseEntity<?> deleteUser(
@PathVariable("username") String username, Authentication authentication) {
if (!userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
// Get the currently authenticated username
String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) {
return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot delete your own account."));
}
// Invalidate all sessions before deleting the user
List<SessionInformation> sessionsInformations =
@@ -468,7 +630,7 @@ public class UserController {
sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId());
}
userService.deleteUser(username);
return new RedirectView("/adminSettings", true);
return ResponseEntity.ok(Map.of("message", "User deleted successfully"));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@@ -499,4 +661,73 @@ public class UserController {
}
return ResponseEntity.ok(apiKey);
}
/**
* Helper method to process a single email invitation.
*
* @param email The email address to invite
* @param teamId The team ID to assign the user to
* @param role The role to assign to the user
* @return InviteResult containing success status and optional error message
*/
private InviteResult processEmailInvite(String email, Long teamId, String role) {
try {
// Validate email format (basic check)
if (!email.contains("@") || !email.contains(".")) {
return InviteResult.failure(email + ": Invalid email format");
}
// Check if user already exists
if (userService.usernameExistsIgnoreCase(email)) {
return InviteResult.failure(email + ": User already exists");
}
// Generate random password
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
// Create user with forceChange=true
userService.saveUser(email, temporaryPassword, teamId, role, true);
// Send invite email
try {
emailService.get().sendInviteEmail(email, email, temporaryPassword);
log.info("Sent invite email to: {}", email);
return InviteResult.success();
} catch (Exception emailEx) {
log.error("Failed to send invite email to {}: {}", email, emailEx.getMessage());
return InviteResult.failure(email + ": User created but email failed to send");
}
} catch (Exception e) {
log.error("Failed to invite user {}: {}", email, e.getMessage());
return InviteResult.failure(email + ": " + e.getMessage());
}
}
/** Result object for individual email invite processing. */
private static class InviteResult {
private final boolean success;
private final String errorMessage;
private InviteResult(boolean success, String errorMessage) {
this.success = success;
this.errorMessage = errorMessage;
}
static InviteResult success() {
return new InviteResult(true, null);
}
static InviteResult failure(String errorMessage) {
return new InviteResult(false, errorMessage);
}
boolean isSuccess() {
return success;
}
String getErrorMessage() {
return errorMessage;
}
}
}
@@ -1,77 +0,0 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Optional;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@Component
public class FirstLoginFilter extends OncePerRequestFilter {
@Lazy private final UserService userService;
public FirstLoginFilter(@Lazy UserService userService) {
this.userService = userService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String method = request.getMethod();
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Check if the request is for static resources
boolean isStaticResource = RequestUriUtils.isStaticResource(contextPath, requestURI);
// If it's a static resource, just continue the filter chain and skip the logic below
if (isStaticResource) {
filterChain.doFilter(request, response);
return;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
Optional<User> user = userService.findByUsernameIgnoreCase(authentication.getName());
if ("GET".equalsIgnoreCase(method)
&& user.isPresent()
&& user.get().isFirstLogin()
&& !(contextPath + "/change-creds").equals(requestURI)) {
response.sendRedirect(contextPath + "/change-creds");
return;
}
}
if (log.isDebugEnabled()) {
HttpSession session = request.getSession(true);
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss");
String creationTime = timeFormat.format(new Date(session.getCreationTime()));
log.debug(
"Request Info - New: {}, creationTimeSession {}, ID: {}, IP: {}, User-Agent: {}, Referer: {}, Request URL: {}",
session.isNew(),
creationTime,
session.getId(),
request.getRemoteAddr(),
request.getHeader("User-Agent"),
request.getHeader("Referer"),
request.getRequestURL().toString());
}
filterChain.doFilter(request, response);
}
}
@@ -73,7 +73,6 @@ public class User implements UserDetails, Serializable {
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id")
@JsonIgnore
private Team team;
@ElementCollection
@@ -81,6 +80,7 @@ public class User implements UserDetails, Serializable {
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
@CreationTimestamp
@@ -73,4 +73,90 @@ public class EmailService {
// Sends the email via the configured mail sender
mailSender.send(message);
}
/**
* Sends a plain text/HTML email without attachments asynchronously.
*
* @param to The recipient email address
* @param subject The email subject
* @param body The email body (can contain HTML)
* @param isHtml Whether the body contains HTML content
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendPlainEmail(String to, String subject, String body, boolean isHtml)
throws MessagingException {
// Validate recipient email address
if (to == null || to.trim().isEmpty()) {
throw new MessagingException("Invalid recipient email address");
}
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
// Creates a MimeMessage to represent the email
MimeMessage message = mailSender.createMimeMessage();
// Helper class to set up the message content
MimeMessageHelper helper = new MimeMessageHelper(message, false);
// Sets the recipient, subject, body, and sender email
helper.addTo(to);
helper.setSubject(subject);
helper.setText(body, isHtml);
helper.setFrom(mailProperties.getFrom());
// Sends the email via the configured mail sender
mailSender.send(message);
}
/**
* Sends an invitation email to a new user with their credentials.
*
* @param to The recipient email address
* @param username The username for the new account
* @param temporaryPassword The temporary password
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendInviteEmail(String to, String username, String temporaryPassword)
throws MessagingException {
String subject = "Welcome to Stirling PDF";
String body =
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
<!-- Logo -->
<div style="text-align: center; padding: 20px; background-color: #222;">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
</div>
<!-- Content -->
<div style="padding: 30px; color: #333;">
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
<p>Hi there,</p>
<p>You have been invited to join the workspace. Below are your login credentials:</p>
<!-- Credentials Box -->
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> %s</p>
<p style="margin: 0;"><strong>Temporary Password:</strong> %s</p>
</div>
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; color: #856404;"><strong> Important:</strong> You will be required to change your password upon first login for security reasons.</p>
</div>
<p>Please keep these credentials secure and do not share them with anyone.</p>
<p style="margin-bottom: 0;"> The Stirling PDF Team</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, temporaryPassword);
sendPlainEmail(to, subject, body, true);
}
}
@@ -642,6 +642,21 @@ public class UserService implements UserServiceInterface {
return null;
}
public boolean isCurrentUserAdmin() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal())) {
return authentication.getAuthorities().stream()
.anyMatch(auth -> Role.ADMIN.getRoleId().equals(auth.getAuthority()));
}
} catch (Exception e) {
log.debug("Error checking admin status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {
@@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@Service
@Slf4j
@@ -51,6 +53,12 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
@Value("${system.serverCertificate.regenerateOnStartup:false}")
private boolean regenerateOnStartup;
private final LicenseKeyChecker licenseKeyChecker;
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
this.licenseKeyChecker = licenseKeyChecker;
}
static {
Security.addProvider(new BouncyCastleProvider());
}
@@ -59,8 +67,13 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
}
private boolean hasProOrEnterpriseAccess() {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.PRO || license == License.ENTERPRISE;
}
public boolean isEnabled() {
return enabled;
return enabled && hasProOrEnterpriseAccess();
}
public boolean hasServerCertificate() {
@@ -73,6 +86,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
return;
}
if (!hasProOrEnterpriseAccess()) {
log.info("Server certificate feature requires Pro or Enterprise license");
return;
}
Path keystorePath = getKeystorePath();
if (!Files.exists(keystorePath) || regenerateOnStartup) {
@@ -88,6 +106,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
}
public KeyStore getServerKeyStore() throws Exception {
if (!hasProOrEnterpriseAccess()) {
throw new IllegalStateException(
"Server certificate feature requires Pro or Enterprise license");
}
if (!enabled || !hasServerCertificate()) {
throw new IllegalStateException("Server certificate is not available");
}
@@ -114,6 +137,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
}
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
if (!hasProOrEnterpriseAccess()) {
throw new IllegalStateException(
"Server certificate feature requires Pro or Enterprise license");
}
// Validate the uploaded certificate
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
uploadedKeyStore.load(p12Stream, password.toCharArray());
@@ -174,6 +202,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
}
private void generateServerCertificate() throws Exception {
if (!hasProOrEnterpriseAccess()) {
throw new IllegalStateException(
"Server certificate feature requires Pro or Enterprise license");
}
// Generate key pair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048, new SecureRandom());
+32 -1
View File
@@ -629,9 +629,40 @@ tasks.named('bootRun') {
tasks.named('build') {
group = 'build'
description = 'Delegates to :stirling-pdf:bootJar'
dependsOn ':stirling-pdf:bootJar'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper'
doFirst {
println "Delegating to :stirling-pdf:bootJar"
}
}
// Task to compile RestartHelper.java
tasks.register('compileRestartHelper', JavaCompile) {
group = 'build'
description = 'Compiles the RestartHelper utility'
source = fileTree(dir: 'scripts', include: 'RestartHelper.java')
classpath = files()
destinationDirectory = file("${buildDir}/restart-helper-classes")
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Task to create restart-helper.jar
tasks.register('buildRestartHelper', Jar) {
group = 'build'
description = 'Builds the restart-helper.jar'
dependsOn 'compileRestartHelper'
from "${buildDir}/restart-helper-classes"
archiveFileName = 'restart-helper.jar'
destinationDirectory = file("${buildDir}/libs")
manifest {
attributes 'Main-Class': 'RestartHelper'
}
doLast {
println "restart-helper.jar created at: ${destinationDirectory.get()}/restart-helper.jar"
}
}
+2 -1
View File
@@ -30,6 +30,7 @@ COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
ARG VERSION_TAG
@@ -113,7 +114,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
+2 -1
View File
@@ -30,6 +30,7 @@ COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
ARG VERSION_TAG
@@ -104,7 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
# Set user and run command
+2 -1
View File
@@ -45,6 +45,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
# Set up necessary directories and permissions
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
@@ -65,7 +66,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
# Set environment variables
ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI
@@ -3489,8 +3489,358 @@
"help": "Help",
"account": "Account",
"config": "Config",
"adminSettings": "Admin Settings",
"allTools": "All Tools"
},
"admin": {
"error": "Error",
"success": "Success",
"expand": "Expand",
"close": "Close",
"status": {
"active": "Active",
"inactive": "Inactive"
},
"settings": {
"title": "Admin Settings",
"workspace": "Workspace",
"fetchError": "Failed to load settings",
"saveError": "Failed to save settings",
"saved": "Settings saved successfully",
"saveSuccess": "Settings saved successfully",
"save": "Save Changes",
"restartRequired": "Restart Required",
"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"
},
"restarting": "Restarting Server",
"restartingMessage": "The server is restarting. Please wait a moment...",
"restartError": "Failed to restart server. Please restart manually.",
"general": {
"title": "General",
"description": "Configure general application settings including branding and default behaviour.",
"ui": "User Interface",
"system": "System",
"appName": "Application Name",
"appName.description": "The name displayed in the browser tab and home page",
"appNameNavbar": "Navbar Brand",
"appNameNavbar.description": "The name displayed in the navigation bar",
"homeDescription": "Home Description",
"homeDescription.description": "The description text shown on the home page",
"defaultLocale": "Default Locale",
"defaultLocale.description": "The default language for new users (e.g., en_US, es_ES)",
"fileUploadLimit": "File Upload Limit",
"fileUploadLimit.description": "Maximum file upload size (e.g., 100MB, 1GB)",
"showUpdate": "Show Update Notifications",
"showUpdate.description": "Display notifications when a new version is available",
"showUpdateOnlyAdmin": "Show Updates to Admins Only",
"showUpdateOnlyAdmin.description": "Restrict update notifications to admin users only",
"customHTMLFiles": "Custom HTML Files",
"customHTMLFiles.description": "Allow serving custom HTML files from the customFiles directory",
"languages": "Available Languages",
"languages.description": "Languages that users can select from (leave empty to enable all languages)",
"customMetadata": "Custom Metadata",
"customMetadata.autoUpdate": "Auto Update Metadata",
"customMetadata.autoUpdate.description": "Automatically update PDF metadata on all processed documents",
"customMetadata.author": "Default Author",
"customMetadata.author.description": "Default author for PDF metadata (e.g., username)",
"customMetadata.creator": "Default Creator",
"customMetadata.creator.description": "Default creator for PDF metadata",
"customMetadata.producer": "Default Producer",
"customMetadata.producer.description": "Default producer for PDF metadata",
"customPaths": "Custom Paths",
"customPaths.description": "Configure custom file system paths for pipeline processing and external tools",
"customPaths.pipeline": "Pipeline Directories",
"customPaths.pipeline.watchedFoldersDir": "Watched Folders Directory",
"customPaths.pipeline.watchedFoldersDir.description": "Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)",
"customPaths.pipeline.finishedFoldersDir": "Finished Folders Directory",
"customPaths.pipeline.finishedFoldersDir.description": "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)",
"customPaths.operations": "External Tool Paths",
"customPaths.operations.weasyprint": "WeasyPrint Executable",
"customPaths.operations.weasyprint.description": "Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)",
"customPaths.operations.unoconvert": "Unoconvert Executable",
"customPaths.operations.unoconvert.description": "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
},
"security": {
"title": "Security",
"description": "Configure authentication, login behaviour, and security policies.",
"ssoNotice": {
"title": "Looking for SSO/SAML settings?",
"message": "OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management."
},
"authentication": "Authentication",
"enableLogin": "Enable Login",
"enableLogin.description": "Require users to log in before accessing the application",
"loginMethod": "Login Method",
"loginMethod.description": "The authentication method to use for user login",
"loginMethod.all": "All Methods",
"loginMethod.normal": "Username/Password Only",
"loginMethod.oauth2": "OAuth2 Only",
"loginMethod.saml2": "SAML2 Only",
"loginAttemptCount": "Login Attempt Limit",
"loginAttemptCount.description": "Maximum number of failed login attempts before account lockout",
"loginResetTimeMinutes": "Login Reset Time (minutes)",
"loginResetTimeMinutes.description": "Time before failed login attempts are reset",
"csrfDisabled": "Disable CSRF Protection",
"csrfDisabled.description": "Disable Cross-Site Request Forgery protection (not recommended)",
"initialLogin": "Initial Login",
"initialLogin.username": "Initial Username",
"initialLogin.username.description": "The username for the initial admin account",
"initialLogin.password": "Initial Password",
"initialLogin.password.description": "The password for the initial admin account",
"jwt": "JWT Configuration",
"jwt.secureCookie": "Secure Cookie",
"jwt.secureCookie.description": "Require HTTPS for JWT cookies (recommended for production)",
"jwt.keyRetentionDays": "Key Retention Days",
"jwt.keyRetentionDays.description": "Number of days to retain old JWT keys for verification",
"jwt.persistence": "Enable Key Persistence",
"jwt.persistence.description": "Store JWT keys persistently to survive server restarts",
"jwt.enableKeyRotation": "Enable Key Rotation",
"jwt.enableKeyRotation.description": "Automatically rotate JWT signing keys periodically",
"jwt.enableKeyCleanup": "Enable Key Cleanup",
"jwt.enableKeyCleanup.description": "Automatically remove expired JWT keys",
"audit": "Audit Logging",
"audit.enabled": "Enable Audit Logging",
"audit.enabled.description": "Track user actions and system events for compliance and security monitoring",
"audit.level": "Audit Level",
"audit.level.description": "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE",
"audit.retentionDays": "Audit Retention (days)",
"audit.retentionDays.description": "Number of days to retain audit logs",
"htmlUrlSecurity": "HTML URL Security",
"htmlUrlSecurity.description": "Configure URL access restrictions for HTML processing to prevent SSRF attacks",
"htmlUrlSecurity.enabled": "Enable URL Security",
"htmlUrlSecurity.enabled.description": "Enable URL security restrictions for HTML to PDF conversions",
"htmlUrlSecurity.level": "Security Level",
"htmlUrlSecurity.level.description": "MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions",
"htmlUrlSecurity.level.max": "Maximum (Whitelist Only)",
"htmlUrlSecurity.level.medium": "Medium (Block Internal)",
"htmlUrlSecurity.level.off": "Off (No Restrictions)",
"htmlUrlSecurity.advanced": "Advanced Settings",
"htmlUrlSecurity.allowedDomains": "Allowed Domains (Whitelist)",
"htmlUrlSecurity.allowedDomains.description": "One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX",
"htmlUrlSecurity.blockedDomains": "Blocked Domains (Blacklist)",
"htmlUrlSecurity.blockedDomains.description": "One domain per line (e.g., malicious.com). Additional domains to block",
"htmlUrlSecurity.internalTlds": "Internal TLDs",
"htmlUrlSecurity.internalTlds.description": "One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns",
"htmlUrlSecurity.networkBlocking": "Network Blocking",
"htmlUrlSecurity.blockPrivateNetworks": "Block Private Networks",
"htmlUrlSecurity.blockPrivateNetworks.description": "Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)",
"htmlUrlSecurity.blockLocalhost": "Block Localhost",
"htmlUrlSecurity.blockLocalhost.description": "Block localhost and loopback addresses (127.x.x.x, ::1)",
"htmlUrlSecurity.blockLinkLocal": "Block Link-Local Addresses",
"htmlUrlSecurity.blockLinkLocal.description": "Block link-local addresses (169.254.x.x, fe80::/10)",
"htmlUrlSecurity.blockCloudMetadata": "Block Cloud Metadata Endpoints",
"htmlUrlSecurity.blockCloudMetadata.description": "Block cloud provider metadata endpoints (169.254.169.254)"
},
"connections": {
"title": "Connections",
"description": "Configure external authentication providers like OAuth2 and SAML.",
"linkedServices": "Linked Services",
"unlinkedServices": "Unlinked Services",
"connect": "Connect",
"disconnect": "Disconnect",
"disconnected": "Provider disconnected successfully",
"disconnectError": "Failed to disconnect provider",
"ssoAutoLogin": "SSO Auto Login",
"ssoAutoLogin.enable": "Enable SSO Auto Login",
"ssoAutoLogin.description": "Automatically redirect to SSO login when authentication is required",
"oauth2": "OAuth2",
"oauth2.enabled": "Enable OAuth2",
"oauth2.enabled.description": "Allow users to authenticate using OAuth2 providers",
"oauth2.provider": "Provider",
"oauth2.provider.description": "The OAuth2 provider to use for authentication",
"oauth2.issuer": "Issuer URL",
"oauth2.issuer.description": "The OAuth2 provider issuer URL",
"oauth2.clientId": "Client ID",
"oauth2.clientId.description": "The OAuth2 client ID from your provider",
"oauth2.clientSecret": "Client Secret",
"oauth2.clientSecret.description": "The OAuth2 client secret from your provider",
"oauth2.useAsUsername": "Use as Username",
"oauth2.useAsUsername.description": "The OAuth2 claim to use as the username (e.g., email, sub)",
"oauth2.autoCreateUser": "Auto Create Users",
"oauth2.autoCreateUser.description": "Automatically create user accounts on first OAuth2 login",
"oauth2.blockRegistration": "Block Registration",
"oauth2.blockRegistration.description": "Prevent new user registration via OAuth2",
"oauth2.scopes": "OAuth2 Scopes",
"oauth2.scopes.description": "Comma-separated list of OAuth2 scopes to request (e.g., openid, profile, email)",
"saml2": "SAML2",
"saml2.enabled": "Enable SAML2",
"saml2.enabled.description": "Allow users to authenticate using SAML2 providers",
"saml2.provider": "Provider",
"saml2.provider.description": "The SAML2 provider name",
"saml2.registrationId": "Registration ID",
"saml2.registrationId.description": "The SAML2 registration identifier",
"saml2.autoCreateUser": "Auto Create Users",
"saml2.autoCreateUser.description": "Automatically create user accounts on first SAML2 login",
"saml2.blockRegistration": "Block Registration",
"saml2.blockRegistration.description": "Prevent new user registration via SAML2"
},
"database": {
"title": "Database",
"description": "Configure custom database connection settings for enterprise deployments.",
"configuration": "Database Configuration",
"enableCustom": "Enable Custom Database",
"enableCustom.description": "Use your own custom database configuration instead of the default embedded database",
"customUrl": "Custom Database URL",
"customUrl.description": "Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.",
"type": "Database Type",
"type.description": "Type of database (not used if custom URL is provided)",
"hostName": "Host Name",
"hostName.description": "Database server hostname (not used if custom URL is provided)",
"port": "Port",
"port.description": "Database server port (not used if custom URL is provided)",
"name": "Database Name",
"name.description": "Name of the database (not used if custom URL is provided)",
"username": "Username",
"username.description": "Database authentication username",
"password": "Password",
"password.description": "Database authentication password"
},
"privacy": {
"title": "Privacy",
"description": "Configure privacy and data collection settings.",
"analytics": "Analytics & Tracking",
"enableAnalytics": "Enable Analytics",
"enableAnalytics.description": "Collect anonymous usage analytics to help improve the application",
"metricsEnabled": "Enable Metrics",
"metricsEnabled.description": "Enable collection of performance and usage metrics",
"searchEngine": "Search Engine Visibility",
"googleVisibility": "Google Visibility",
"googleVisibility.description": "Allow search engines to index this application"
},
"advanced": {
"title": "Advanced",
"description": "Configure advanced features and experimental functionality.",
"features": "Feature Flags",
"processing": "Processing",
"endpoints": "Endpoints",
"endpoints.manage": "Manage API Endpoints",
"endpoints.description": "Endpoint management is configured via YAML. See documentation for details on enabling/disabling specific endpoints.",
"enableAlphaFunctionality": "Enable Alpha Features",
"enableAlphaFunctionality.description": "Enable experimental and alpha-stage features (may be unstable)",
"enableUrlToPDF": "Enable URL to PDF",
"enableUrlToPDF.description": "Allow conversion of web pages to PDF documents",
"maxDPI": "Maximum DPI",
"maxDPI.description": "Maximum DPI for image processing (0 = unlimited)",
"tessdataDir": "Tessdata Directory",
"tessdataDir.description": "Path to the tessdata directory for OCR language files",
"disableSanitize": "Disable HTML Sanitization",
"disableSanitize.description": "WARNING: Security risk - disabling HTML sanitization can lead to XSS vulnerabilities",
"tempFileManagement": "Temp File Management",
"tempFileManagement.description": "Configure temporary file storage and cleanup behavior",
"tempFileManagement.baseTmpDir": "Base Temp Directory",
"tempFileManagement.baseTmpDir.description": "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)",
"tempFileManagement.libreofficeDir": "LibreOffice Temp Directory",
"tempFileManagement.libreofficeDir.description": "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)",
"tempFileManagement.systemTempDir": "System Temp Directory",
"tempFileManagement.systemTempDir.description": "System temp directory to clean (only used if cleanupSystemTemp is enabled)",
"tempFileManagement.prefix": "Temp File Prefix",
"tempFileManagement.prefix.description": "Prefix for temp file names",
"tempFileManagement.maxAgeHours": "Max Age (hours)",
"tempFileManagement.maxAgeHours.description": "Maximum age in hours before temp files are cleaned up",
"tempFileManagement.cleanupIntervalMinutes": "Cleanup Interval (minutes)",
"tempFileManagement.cleanupIntervalMinutes.description": "How often to run cleanup (in minutes)",
"tempFileManagement.startupCleanup": "Startup Cleanup",
"tempFileManagement.startupCleanup.description": "Clean up old temp files on application startup",
"tempFileManagement.cleanupSystemTemp": "Cleanup System Temp",
"tempFileManagement.cleanupSystemTemp.description": "Whether to clean broader system temp directory (use with caution)",
"processExecutor": "Process Executor Limits",
"processExecutor.description": "Configure session limits and timeouts for each process executor",
"processExecutor.sessionLimit": "Session Limit",
"processExecutor.sessionLimit.description": "Maximum concurrent instances",
"processExecutor.timeout": "Timeout (minutes)",
"processExecutor.timeout.description": "Maximum execution time",
"processExecutor.libreOffice": "LibreOffice",
"processExecutor.pdfToHtml": "PDF to HTML",
"processExecutor.qpdf": "QPDF",
"processExecutor.tesseract": "Tesseract OCR",
"processExecutor.pythonOpenCv": "Python OpenCV",
"processExecutor.weasyPrint": "WeasyPrint",
"processExecutor.installApp": "Install App",
"processExecutor.calibre": "Calibre",
"processExecutor.ghostscript": "Ghostscript",
"processExecutor.ocrMyPdf": "OCRmyPDF"
},
"mail": {
"title": "Mail Server",
"description": "Configure SMTP settings for sending email notifications.",
"smtp": "SMTP Configuration",
"enabled": "Enable Mail",
"enabled.description": "Enable email notifications and SMTP functionality",
"host": "SMTP Host",
"host.description": "The hostname or IP address of your SMTP server",
"port": "SMTP Port",
"port.description": "The port number for SMTP connection (typically 25, 465, or 587)",
"username": "SMTP Username",
"username.description": "Username for SMTP authentication",
"password": "SMTP Password",
"password.description": "Password for SMTP authentication",
"from": "From Address",
"from.description": "The email address to use as the sender",
"enableInvites": "Enable Email Invites",
"enableInvites.description": "Allow admins to invite users via email with auto-generated passwords"
},
"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",
"privacyPolicy.description": "URL or filename to privacy policy",
"accessibilityStatement": "Accessibility Statement",
"accessibilityStatement.description": "URL or filename to accessibility statement",
"cookiePolicy": "Cookie Policy",
"cookiePolicy.description": "URL or filename to cookie policy",
"impressum": "Impressum",
"impressum.description": "URL or filename to impressum (required in some jurisdictions)"
},
"premium": {
"title": "Premium & Enterprise",
"description": "Configure your premium or enterprise license key.",
"license": "License Configuration",
"key": "License Key",
"key.description": "Enter your premium or enterprise license key",
"enabled": "Enable Premium Features",
"enabled.description": "Enable license key checks for pro/enterprise features",
"movedFeatures": {
"title": "Premium Features Distributed",
"message": "Premium and Enterprise features are now organized in their respective sections:"
}
},
"features": {
"title": "Features",
"description": "Configure optional features and functionality.",
"serverCertificate": "Server Certificate",
"serverCertificate.description": "Configure server-side certificate generation for \"Sign with Stirling-PDF\" functionality",
"serverCertificate.enabled": "Enable Server Certificate",
"serverCertificate.enabled.description": "Enable server-side certificate for \"Sign with Stirling-PDF\" option",
"serverCertificate.organizationName": "Organization Name",
"serverCertificate.organizationName.description": "Organization name for generated certificates",
"serverCertificate.validity": "Certificate Validity (days)",
"serverCertificate.validity.description": "Number of days the certificate will be valid",
"serverCertificate.regenerateOnStartup": "Regenerate on Startup",
"serverCertificate.regenerateOnStartup.description": "Generate new certificate on each application startup"
},
"endpoints": {
"title": "API Endpoints",
"description": "Control which API endpoints and endpoint groups are available.",
"management": "Endpoint Management",
"toRemove": "Disabled Endpoints",
"toRemove.description": "Select individual endpoints to disable",
"groupsToRemove": "Disabled Endpoint Groups",
"groupsToRemove.description": "Select endpoint groups to disable",
"note": "Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect."
}
}
},
"fileUpload": {
"selectFile": "Select a file",
"selectFiles": "Select files",
@@ -4052,5 +4402,195 @@
"finish": "Finish",
"startTour": "Start Tour",
"startTourDescription": "Take a guided tour of Stirling PDF's key features"
},
"workspace": {
"title": "Workspace",
"people": {
"title": "People",
"description": "Manage workspace members and their permissions",
"loading": "Loading people...",
"searchMembers": "Search members...",
"addMembers": "Add Members",
"inviteMembers": "Invite Members",
"inviteMembers.subtitle": "Type or paste in emails below, separated by commas. Your workspace will be billed by members.",
"user": "User",
"role": "Role",
"team": "Team",
"status": "Status",
"actions": "Actions",
"noMembersFound": "No members found",
"active": "Active",
"disabled": "Disabled",
"activeSession": "Active session",
"member": "Member",
"admin": "Admin",
"roleDescriptions": {
"admin": "Can manage settings and invite members, with full administrative access.",
"member": "Can view and edit shared files, but cannot manage workspace settings or members."
},
"editRole": "Edit Role",
"enable": "Enable",
"disable": "Disable",
"deleteUser": "Delete User",
"deleteUserSuccess": "User deleted successfully",
"deleteUserError": "Failed to delete user",
"confirmDelete": "Are you sure you want to delete this user? This action cannot be undone.",
"addMember": {
"title": "Add Member",
"username": "Username (Email)",
"usernamePlaceholder": "[email protected]",
"password": "Password",
"passwordPlaceholder": "Enter password",
"role": "Role",
"team": "Team (Optional)",
"teamPlaceholder": "Select a team",
"forcePasswordChange": "Force password change on first login",
"cancel": "Cancel",
"submit": "Add Member",
"usernameRequired": "Username and password are required",
"passwordTooShort": "Password must be at least 6 characters",
"success": "User created successfully",
"error": "Failed to create user"
},
"editMember": {
"title": "Edit Member",
"editing": "Editing:",
"role": "Role",
"team": "Team (Optional)",
"teamPlaceholder": "Select a team",
"cancel": "Cancel",
"submit": "Update Member",
"success": "User updated successfully",
"error": "Failed to update user"
},
"toggleEnabled": {
"success": "User status updated successfully",
"error": "Failed to update user status"
},
"delete": {
"success": "User deleted successfully",
"error": "Failed to delete user"
},
"emailInvite": {
"tab": "Email Invite",
"description": "Type or paste in emails below, separated by commas. Users will receive login credentials via email.",
"emails": "Email Addresses",
"emailsPlaceholder": "[email protected], [email protected]",
"emailsRequired": "At least one email address is required",
"submit": "Send Invites",
"success": "user(s) invited successfully",
"partialSuccess": "Some invites failed",
"allFailed": "Failed to invite users",
"error": "Failed to send invites"
},
"directInvite": {
"tab": "Direct Create"
},
"inviteMode": {
"username": "Username",
"email": "Email",
"emailDisabled": "Email invites require SMTP configuration and mail.enableInvites=true in settings"
}
},
"teams": {
"title": "Teams",
"description": "Manage teams and organize workspace members",
"loading": "Loading teams...",
"loadingDetails": "Loading team details...",
"createNewTeam": "Create New Team",
"teamName": "Team Name",
"totalMembers": "Total Members",
"actions": "Actions",
"noTeamsFound": "No teams found",
"noMembers": "No members in this team",
"system": "System",
"addMember": "Add Member",
"viewTeam": "View Team",
"removeMember": "Remove from team",
"cannotRemoveFromSystemTeam": "Cannot remove from system team",
"renameTeamLabel": "Rename Team",
"deleteTeamLabel": "Delete Team",
"cannotDeleteInternal": "Cannot delete the Internal team",
"confirmDelete": "Are you sure you want to delete this team? This team must be empty to delete.",
"confirmRemove": "Remove user from this team?",
"cannotRenameInternal": "Cannot rename the Internal team",
"cannotAddToInternal": "Cannot add members to the Internal team",
"teamNotFound": "Team not found",
"backToTeams": "Back to Teams",
"memberCount": "{{count}} members",
"removeMemberSuccess": "User removed from team",
"removeMemberError": "Failed to remove user from team",
"createTeam": {
"title": "Create New Team",
"teamName": "Team Name",
"teamNamePlaceholder": "Enter team name",
"cancel": "Cancel",
"submit": "Create Team",
"nameRequired": "Team name is required",
"success": "Team created successfully",
"error": "Failed to create team"
},
"renameTeam": {
"title": "Rename Team",
"renaming": "Renaming:",
"newTeamName": "New Team Name",
"newTeamNamePlaceholder": "Enter new team name",
"cancel": "Cancel",
"submit": "Rename Team",
"nameRequired": "Team name is required",
"success": "Team renamed successfully",
"error": "Failed to rename team"
},
"deleteTeam": {
"success": "Team deleted successfully",
"error": "Failed to delete team. Make sure the team is empty.",
"teamMustBeEmpty": "Team must be empty before deletion"
},
"addMemberToTeam": {
"title": "Add Member to Team",
"addingTo": "Adding to",
"selectUser": "Select User",
"selectUserPlaceholder": "Choose a user",
"selectUserRequired": "Please select a user",
"currentlyIn": "currently in",
"willBeMoved": "Note: This user will be moved from their current team to this team.",
"cancel": "Cancel",
"submit": "Add Member",
"userRequired": "Please select a user",
"success": "Member added to team successfully",
"error": "Failed to add member to team"
},
"changeTeam": {
"label": "Change Team",
"title": "Change Team",
"changing": "Moving",
"selectTeam": "Select Team",
"selectTeamPlaceholder": "Choose a team",
"selectTeamRequired": "Please select a team",
"success": "Team changed successfully",
"error": "Failed to change team",
"submit": "Change Team"
}
}
},
"firstLogin": {
"title": "First Time Login",
"welcomeTitle": "Welcome!",
"welcomeMessage": "For security reasons, you must change your password on your first login.",
"loggedInAs": "Logged in as",
"error": "Error",
"currentPassword": "Current Password",
"enterCurrentPassword": "Enter your current password",
"newPassword": "New Password",
"enterNewPassword": "Enter new password (min 8 characters)",
"confirmPassword": "Confirm New Password",
"reEnterNewPassword": "Re-enter new password",
"changePassword": "Change Password",
"allFieldsRequired": "All fields are required",
"passwordsDoNotMatch": "New passwords do not match",
"passwordTooShort": "Password must be at least 8 characters",
"passwordMustBeDifferent": "New password must be different from current password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"passwordChangeFailed": "Failed to change password. Please check your current password."
}
}
@@ -190,7 +190,14 @@ export default function Workbench() {
{renderMainContent()}
</Box>
<Footer analyticsEnabled={config?.enableAnalytics === true} />
<Footer
analyticsEnabled={config?.enableAnalytics === true}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
impressum={config?.impressum}
accessibilityStatement={config?.accessibilityStatement}
/>
</Box>
);
}
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { updateSupportedLanguages } from '@app/i18n';
/**
* Component that loads app configuration and applies it to the application.
* This includes:
* - Filtering available languages based on config.languages
*
* Place this component high in the component tree, after i18n has initialized.
*/
export default function AppConfigLoader() {
const { config, loading } = useAppConfig();
useEffect(() => {
if (!loading && config) {
// Update supported languages if config specifies a language filter
updateSupportedLanguages(config.languages);
}
}, [config, loading]);
// This component doesn't render anything
return null;
}
@@ -5,6 +5,7 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import Overview from '@app/components/shared/config/configSections/Overview';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
import { NavKey } from '@app/components/shared/config/types';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
@@ -16,6 +17,7 @@ interface AppConfigModalProps {
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('overview');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config } = useAppConfig();
useEffect(() => {
const handler = (ev: Event) => {
@@ -44,13 +46,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
console.log('Logout placeholder for SaaS compatibility');
};
// Get isAdmin from app config (based on JWT role)
const isAdmin = config?.isAdmin ?? false;
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
handleLogout,
isAdmin
),
[]
[isAdmin]
);
const activeLabel = useMemo(() => {
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useFileState } from '@app/contexts/FileContext';
import { useFileActions } from '@app/contexts/file/fileHooks';
import CloseIcon from '@mui/icons-material/Close';
import { Z_INDEX_TOAST } from '@app/styles/zIndex';
interface DismissAllErrorsButtonProps {
className?: string;
@@ -38,7 +39,7 @@ const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ classNa
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
zIndex: Z_INDEX_TOAST,
pointerEvents: 'auto'
}}
>
@@ -0,0 +1,159 @@
import { useState } from 'react';
import { Modal, Stack, Text, PasswordInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { accountService } from '@app/services/accountService';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface FirstLoginModalProps {
opened: boolean;
onPasswordChanged: () => void;
username: string;
}
/**
* FirstLoginModal
*
* Forces first-time users to change their password.
* Cannot be dismissed until password is successfully changed.
*/
export default function FirstLoginModal({ opened, onPasswordChanged, username }: FirstLoginModalProps) {
const { t } = useTranslation();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
return;
}
if (newPassword.length < 8) {
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
return;
}
if (newPassword === currentPassword) {
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
return;
}
try {
setLoading(true);
setError('');
await accountService.changePassword(currentPassword, newPassword);
alert({
alertType: 'success',
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
});
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
// Wait a moment for the user to see the success message
// Then the backend will have logged them out, and onPasswordChanged will handle redirect
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err: any) {
console.error('Failed to change password:', err);
setError(
err.response?.data?.message ||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
);
} finally {
setLoading(false);
}
};
return (
<Modal
opened={opened}
onClose={() => {}} // Cannot close
title={t('firstLogin.title', 'First Time Login')}
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
centered
size="md"
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="md">
<Alert
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
title={t('firstLogin.welcomeTitle', 'Welcome!')}
color="blue"
>
<Text size="sm">
{t(
'firstLogin.welcomeMessage',
'For security reasons, you must change your password on your first login.'
)}
</Text>
</Alert>
<Text size="sm" fw={500}>
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
</Text>
{error && (
<Alert
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
title={t('firstLogin.error', 'Error')}
color="red"
>
{error}
</Alert>
)}
<PasswordInput
label={t('firstLogin.currentPassword', 'Current Password')}
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
/>
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required
/>
<PasswordInput
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
/>
<Button
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!currentPassword || !newPassword || !confirmPassword}
mt="md"
>
{t('firstLogin.changePassword', 'Change Password')}
</Button>
</Stack>
</Modal>
);
}
+31 -6
View File
@@ -12,14 +12,19 @@ interface FooterProps {
}
export default function Footer({
privacyPolicy = 'https://www.stirling.com/legal/privacy-policy',
termsAndConditions = 'https://www.stirling.com/legal/terms-of-service',
accessibilityStatement = 'accessibility',
privacyPolicy,
termsAndConditions,
accessibilityStatement,
cookiePolicy,
impressum,
analyticsEnabled = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
return (
<div style={{
height: 'var(--footer-height)',
@@ -43,7 +48,7 @@ export default function Footer({
>
{t('survey.nav', 'Survey')}
</a>
{privacyPolicy && (
{isValidLink(privacyPolicy) && (
<a
className="footer-link px-3"
target="_blank"
@@ -53,7 +58,7 @@ export default function Footer({
{t('legal.privacy', 'Privacy Policy')}
</a>
)}
{termsAndConditions && (
{isValidLink(termsAndConditions) && (
<a
className="footer-link px-3"
target="_blank"
@@ -63,7 +68,7 @@ export default function Footer({
{t('legal.terms', 'Terms and Conditions')}
</a>
)}
{accessibilityStatement && (
{isValidLink(accessibilityStatement) && (
<a
className="footer-link px-3"
target="_blank"
@@ -73,6 +78,26 @@ export default function Footer({
{t('legal.accessibility', 'Accessibility')}
</a>
)}
{isValidLink(cookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={cookiePolicy}
>
{t('legal.cookie', 'Cookie Policy')}
</a>
)}
{isValidLink(impressum) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={impressum}
>
{t('legal.impressum', 'Impressum')}
</a>
)}
{analyticsEnabled && (
<button
className="footer-link px-3"
@@ -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>
);
}
@@ -0,0 +1,68 @@
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 '@app/styles/zIndex';
interface RestartConfirmationModalProps {
opened: boolean;
onClose: () => void;
onRestart: () => void;
}
export default function RestartConfirmationModal({
opened,
onClose,
onRestart,
}: RestartConfirmationModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text fw={600} size="lg">
{t('admin.settings.restart.title', 'Restart Required')}
</Text>
}
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
withinPortal
>
<Stack gap="lg">
<Text size="sm">
{t(
'admin.settings.restart.message',
'Settings have been saved successfully. A server restart is required for the changes to take effect.'
)}
</Text>
<Text size="sm" c="dimmed">
{t(
'admin.settings.restart.question',
'Would you like to restart the server now or later?'
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
leftSection={<ScheduleIcon style={{ fontSize: 16 }} />}
onClick={onClose}
>
{t('admin.settings.restart.later', 'Restart Later')}
</Button>
<Button
color="blue"
leftSection={<RefreshIcon style={{ fontSize: 16 }} />}
onClick={onRestart}
>
{t('admin.settings.restart.now', 'Restart Now')}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -2,6 +2,18 @@ import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
export interface ConfigNavItem {
key: NavKey;
@@ -28,6 +40,7 @@ export interface ConfigColors {
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void,
isAdmin: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@@ -41,6 +54,23 @@ export const createConfigNavSections = (
},
],
},
{
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
},
],
},
{
title: 'Preferences',
items: [
@@ -60,5 +90,74 @@ export const createConfigNavSections = (
},
];
// Add Admin Settings section if user is admin
if (isAdmin) {
sections.push({
title: 'Admin Settings',
items: [
{
key: 'adminGeneral',
label: 'General',
icon: 'settings-rounded',
component: <AdminGeneralSection />
},
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />
},
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />
},
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
component: <AdminFeaturesSection />
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
},
],
});
}
return sections;
};
@@ -0,0 +1,821 @@
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 '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
interface AdvancedSettingsData {
enableAlphaFunctionality?: boolean;
maxDPI?: number;
enableUrlToPDF?: boolean;
tessdataDir?: string;
disableSanitize?: boolean;
tempFileManagement?: {
baseTmpDir?: string;
libreofficeDir?: string;
systemTempDir?: string;
prefix?: string;
maxAgeHours?: number;
cleanupIntervalMinutes?: number;
startupCleanup?: boolean;
cleanupSystemTemp?: boolean;
};
processExecutor?: {
sessionLimit?: {
libreOfficeSessionLimit?: number;
pdfToHtmlSessionLimit?: number;
qpdfSessionLimit?: number;
tesseractSessionLimit?: number;
pythonOpenCvSessionLimit?: number;
weasyPrintSessionLimit?: number;
installAppSessionLimit?: number;
calibreSessionLimit?: number;
ghostscriptSessionLimit?: number;
ocrMyPdfSessionLimit?: number;
};
timeoutMinutes?: {
libreOfficetimeoutMinutes?: number;
pdfToHtmltimeoutMinutes?: number;
pythonOpenCvtimeoutMinutes?: number;
weasyPrinttimeoutMinutes?: number;
installApptimeoutMinutes?: number;
calibretimeoutMinutes?: number;
tesseractTimeoutMinutes?: number;
qpdfTimeoutMinutes?: number;
ghostscriptTimeoutMinutes?: number;
ocrMyPdfTimeoutMinutes?: number;
};
};
}
export default function AdminAdvancedSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AdvancedSettingsData>({
sectionName: 'advanced',
fetchTransformer: async () => {
const [systemResponse, processExecutorResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/system'),
apiClient.get('/api/v1/admin/settings/section/processExecutor')
]);
const systemData = systemResponse.data || {};
const processExecutorData = processExecutorResponse.data || {};
const result: any = {
enableAlphaFunctionality: systemData.enableAlphaFunctionality || false,
maxDPI: systemData.maxDPI || 0,
enableUrlToPDF: systemData.enableUrlToPDF || false,
tessdataDir: systemData.tessdataDir || '',
disableSanitize: systemData.disableSanitize || false,
tempFileManagement: systemData.tempFileManagement || {
baseTmpDir: '',
libreofficeDir: '',
systemTempDir: '',
prefix: 'stirling-pdf-',
maxAgeHours: 24,
cleanupIntervalMinutes: 30,
startupCleanup: true,
cleanupSystemTemp: false
},
processExecutor: processExecutorData || {}
};
// 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,
'system.enableUrlToPDF': settings.enableUrlToPDF,
'system.tessdataDir': settings.tessdataDir,
'system.disableSanitize': settings.disableSanitize
};
// Add temp file management settings
if (settings.tempFileManagement) {
deltaSettings['system.tempFileManagement.baseTmpDir'] = settings.tempFileManagement.baseTmpDir;
deltaSettings['system.tempFileManagement.libreofficeDir'] = settings.tempFileManagement.libreofficeDir;
deltaSettings['system.tempFileManagement.systemTempDir'] = settings.tempFileManagement.systemTempDir;
deltaSettings['system.tempFileManagement.prefix'] = settings.tempFileManagement.prefix;
deltaSettings['system.tempFileManagement.maxAgeHours'] = settings.tempFileManagement.maxAgeHours;
deltaSettings['system.tempFileManagement.cleanupIntervalMinutes'] = settings.tempFileManagement.cleanupIntervalMinutes;
deltaSettings['system.tempFileManagement.startupCleanup'] = settings.tempFileManagement.startupCleanup;
deltaSettings['system.tempFileManagement.cleanupSystemTemp'] = settings.tempFileManagement.cleanupSystemTemp;
}
// Add process executor settings
if (settings.processExecutor?.sessionLimit) {
Object.entries(settings.processExecutor.sessionLimit).forEach(([key, value]) => {
deltaSettings[`processExecutor.sessionLimit.${key}`] = value;
});
}
if (settings.processExecutor?.timeoutMinutes) {
Object.entries(settings.processExecutor.timeoutMinutes).forEach(([key, value]) => {
deltaSettings[`processExecutor.timeoutMinutes.${key}`] = value;
});
}
return {
sectionData: {},
deltaSettings
};
}
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.advanced.description', 'Configure advanced features and experimental functionality.')}
</Text>
</div>
{/* Feature Flags */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.features', 'Feature Flags')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality', 'Enable Alpha Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')}
</Text>
</div>
<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' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF', 'Enable URL to PDF')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')}
</Text>
</div>
<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' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize', 'Disable HTML Sanitization')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.disableSanitize || false}
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
/>
<PendingBadge show={isFieldPending('disableSanitize')} />
</Group>
</div>
</Stack>
</Paper>
{/* Processing Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.processing', 'Processing')}</Text>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.advanced.maxDPI', 'Maximum DPI')}</span>
<PendingBadge show={isFieldPending('maxDPI')} />
</Group>
}
description={t('admin.settings.advanced.maxDPI.description', 'Maximum DPI for image processing (0 = unlimited)')}
value={settings.maxDPI || 0}
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
min={0}
max={3000}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}</span>
<PendingBadge show={isFieldPending('tessdataDir')} />
</Group>
}
description={t('admin.settings.advanced.tessdataDir.description', 'Path to the directory containing Tessdata files for OCR')}
value={settings.tessdataDir || ''}
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
placeholder="/usr/share/tessdata"
/>
</div>
</Stack>
</Paper>
{/* Temp File Management */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement', 'Temp File Management')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.advanced.tempFileManagement.description', 'Configure temporary file storage and cleanup behavior')}
</Text>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.baseTmpDir', 'Base Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.baseTmpDir.description', 'Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)')}
value={settings.tempFileManagement?.baseTmpDir || ''}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value }
})}
placeholder="Default: java.io.tmpdir/stirling-pdf"
/>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.libreofficeDir', 'LibreOffice Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.libreofficeDir.description', 'Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)')}
value={settings.tempFileManagement?.libreofficeDir || ''}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value }
})}
placeholder="Default: baseTmpDir/libreoffice"
/>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.systemTempDir', 'System Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.systemTempDir.description', 'System temp directory to clean (only used if cleanupSystemTemp is enabled)')}
value={settings.tempFileManagement?.systemTempDir || ''}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value }
})}
placeholder="System temp directory path"
/>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.prefix', 'Temp File Prefix')}
description={t('admin.settings.advanced.tempFileManagement.prefix.description', 'Prefix for temp file names')}
value={settings.tempFileManagement?.prefix || 'stirling-pdf-'}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value }
})}
placeholder="stirling-pdf-"
/>
</div>
<div>
<NumberInput
label={t('admin.settings.advanced.tempFileManagement.maxAgeHours', 'Max Age (hours)')}
description={t('admin.settings.advanced.tempFileManagement.maxAgeHours.description', 'Maximum age in hours before temp files are cleaned up')}
value={settings.tempFileManagement?.maxAgeHours ?? 24}
onChange={(value) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, maxAgeHours: Number(value) }
})}
min={1}
max={720}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes', 'Cleanup Interval (minutes)')}
description={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.description', 'How often to run cleanup (in minutes)')}
value={settings.tempFileManagement?.cleanupIntervalMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupIntervalMinutes: Number(value) }
})}
min={1}
max={1440}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.startupCleanup', 'Startup Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')}
</Text>
</div>
<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' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp', 'Cleanup System Temp')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.description', 'Whether to clean broader system temp directory (use with caution)')}
</Text>
</div>
<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>
{/* Process Executor Limits */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm">{t('admin.settings.advanced.processExecutor', 'Process Executor Limits')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.advanced.processExecutor.description', 'Configure session limits and timeouts for each process executor')}
</Text>
<Accordion variant="separated">
{/* LibreOffice */}
<Accordion.Item value="libreOffice">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.libreOffice', 'LibreOffice')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.libreOfficeSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, libreOfficeSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.libreOfficetimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, libreOfficetimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* PDF to HTML */}
<Accordion.Item value="pdfToHtml">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.pdfToHtml', 'PDF to HTML')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pdfToHtmlSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, pdfToHtmlSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pdfToHtmltimeoutMinutes ?? 20}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, pdfToHtmltimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* QPDF */}
<Accordion.Item value="qpdf">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.qpdf', 'QPDF')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.qpdfSessionLimit ?? 4}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, qpdfSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.qpdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, qpdfTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Tesseract OCR */}
<Accordion.Item value="tesseract">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.tesseract', 'Tesseract OCR')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.tesseractSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, tesseractSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.tesseractTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, tesseractTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Python OpenCV */}
<Accordion.Item value="pythonOpenCv">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.pythonOpenCv', 'Python OpenCV')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pythonOpenCvSessionLimit ?? 8}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, pythonOpenCvSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pythonOpenCvtimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, pythonOpenCvtimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* WeasyPrint */}
<Accordion.Item value="weasyPrint">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.weasyPrint', 'WeasyPrint')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.weasyPrintSessionLimit ?? 16}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, weasyPrintSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.weasyPrinttimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, weasyPrinttimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Install App */}
<Accordion.Item value="installApp">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.installApp', 'Install App')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.installAppSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, installAppSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.installApptimeoutMinutes ?? 60}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, installApptimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Calibre */}
<Accordion.Item value="calibre">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.calibre', 'Calibre')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.calibreSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, calibreSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.calibretimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, calibretimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Ghostscript */}
<Accordion.Item value="ghostscript">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.ghostscript', 'Ghostscript')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ghostscriptSessionLimit ?? 8}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, ghostscriptSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ghostscriptTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, ghostscriptTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* OCRmyPDF */}
<Accordion.Item value="ocrMyPdf">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.ocrMyPdf', 'OCRmyPDF')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ocrMyPdfSessionLimit ?? 2}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, ocrMyPdfSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ocrMyPdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, ocrMyPdfTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,409 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
import {
ALL_PROVIDERS,
Provider,
} from '@app/components/shared/config/configSections/providerDefinitions';
import apiClient from '@app/services/apiClient';
interface ConnectionsSettingsData {
oauth2?: {
enabled?: boolean;
issuer?: string;
clientId?: string;
clientSecret?: string;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
useAsUsername?: string;
scopes?: string;
client?: {
[key: string]: any;
};
};
saml2?: {
[key: string]: any;
};
mail?: {
enabled?: boolean;
enableInvites?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
};
ssoAutoLogin?: boolean;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
fetchTransformer: async () => {
// Fetch security settings (oauth2, saml2)
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
const securityData = securityResponse.data || {};
// Fetch mail settings
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 apiClient.get('/api/v1/admin/settings/section/premium');
const premiumData = premiumResponse.data || {};
const result: any = {
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {},
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || 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: () => {
// 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') {
return settings.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
return settings.mail?.enabled === true;
}
if (provider.id === 'oauth2-generic') {
return settings.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings.oauth2?.client?.[provider.id];
return !!(providerSettings?.clientId);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings.saml2 || {};
}
if (provider.id === 'smtp') {
return settings.mail || {};
}
if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings are at the root oauth2 level
return {
enabled: settings.oauth2?.enabled,
provider: settings.oauth2?.provider,
issuer: settings.oauth2?.issuer,
clientId: settings.oauth2?.clientId,
clientSecret: settings.oauth2?.clientSecret,
scopes: settings.oauth2?.scopes,
useAsUsername: settings.oauth2?.useAsUsername,
autoCreateUser: settings.oauth2?.autoCreateUser,
blockRegistration: settings.oauth2?.blockRegistration,
};
}
// Specific OAuth2 provider settings
return settings.oauth2?.client?.[provider.id] || {};
};
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', providerSettings);
if (response.status === 200) {
await fetchSettings(); // Refresh settings
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} else {
// OAuth2/SAML2 use delta settings
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
// SAML2 settings
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.saml2.${key}`] = providerSettings[key];
});
} else if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings at root level
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.${key}`] = providerSettings[key];
});
} else {
// Specific OAuth2 provider (google, github, keycloak)
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.client.${provider.id}.${key}`] = providerSettings[key];
});
}
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
await fetchSettings(); // Refresh settings
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const handleProviderDisconnect = async (provider: Provider) => {
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false });
if (response.status === 200) {
await fetchSettings();
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to disconnect');
}
} else {
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
deltaSettings['security.saml2.enabled'] = false;
} else if (provider.id === 'oauth2-generic') {
deltaSettings['security.oauth2.enabled'] = false;
} else {
// Clear all fields for specific OAuth2 provider
provider.fields.forEach((field) => {
deltaSettings[`security.oauth2.client.${provider.id}.${field.key}`] = '';
});
}
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
await fetchSettings();
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to disconnect');
}
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.connections.disconnectError', 'Failed to disconnect provider'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const handleSSOAutoLoginSave = async () => {
try {
const deltaSettings = {
'premium.proFeatures.ssoAutoLogin': settings.ssoAutoLogin
};
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
return (
<Stack gap="xl">
{/* Header */}
<div>
<Text fw={600} size="lg">
{t('admin.settings.connections.title', 'Connections')}
</Text>
<Text size="sm" c="dimmed">
{t(
'admin.settings.connections.description',
'Configure external authentication providers like OAuth2 and SAML.'
)}
</Text>
</div>
{/* SSO Auto Login - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin', 'SSO Auto Login')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.ssoAutoLogin.enable', 'Enable SSO Auto Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.ssoAutoLogin.description', 'Automatically redirect to SSO login when authentication is required')}
</Text>
</div>
<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>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.linkedServices', 'Linked Services')}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
settings={getProviderSettings(provider)}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
onDisconnect={() => handleProviderDisconnect(provider)}
/>
))}
</Stack>
</div>
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.unlinkedServices', 'Unlinked Services')}
</Text>
<Stack gap="sm">
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={false}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
/>
))}
</Stack>
</div>
)}
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,272 @@
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 '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
interface DatabaseSettingsData {
enableCustomDatabase?: boolean;
customDatabaseUrl?: string;
username?: string;
password?: string;
type?: string;
hostName?: string;
port?: number;
name?: string;
}
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
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 || {};
// Extract datasource from system response and handle pending
const datasource = systemData.datasource || {
enableCustomDatabase: false,
customDatabaseUrl: '',
username: '',
password: '',
type: 'postgresql',
hostName: 'localhost',
port: 5432,
name: 'postgres'
};
// 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,
'system.datasource.username': settings.username,
'system.datasource.password': settings.password,
'system.datasource.type': settings.type,
'system.datasource.hostName': settings.hostName,
'system.datasource.port': settings.port,
'system.datasource.name': settings.name
};
return {
sectionData: {},
deltaSettings
};
}
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">{t('admin.settings.database.title', 'Database')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.database.description', 'Configure custom database connection settings for enterprise deployments.')}
</Text>
</div>
<Badge color="grape" size="lg">ENTERPRISE</Badge>
</Group>
</div>
{/* Database Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.database.configuration', 'Database Configuration')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.database.enableCustom', 'Enable Custom Database')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableCustomDatabase || false}
onChange={(e) => setSettings({ ...settings, enableCustomDatabase: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
</Group>
</div>
{settings.enableCustomDatabase && (
<>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.customUrl', 'Custom Database URL')}</span>
<PendingBadge show={isFieldPending('customDatabaseUrl')} />
</Group>
}
description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')}
value={settings.customDatabaseUrl || ''}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
/>
</div>
<div>
<Select
label={
<Group gap="xs">
<span>{t('admin.settings.database.type', 'Database Type')}</span>
<PendingBadge show={isFieldPending('type')} />
</Group>
}
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
value={settings.type || 'postgresql'}
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
data={[
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'h2', label: 'H2' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' }
]}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.hostName', 'Host Name')}</span>
<PendingBadge show={isFieldPending('hostName')} />
</Group>
}
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
value={settings.hostName || ''}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.port', 'Port')}</span>
<PendingBadge show={isFieldPending('port')} />
</Group>
}
description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')}
value={settings.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.name', 'Database Name')}</span>
<PendingBadge show={isFieldPending('name')} />
</Group>
}
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
value={settings.name || ''}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.username', 'Username')}</span>
<PendingBadge show={isFieldPending('username')} />
</Group>
}
description={t('admin.settings.database.username.description', 'Database authentication username')}
value={settings.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
/>
</div>
<div>
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.password', 'Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
}
description={t('admin.settings.database.password.description', 'Database authentication password')}
value={settings.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
placeholder="••••••••"
/>
</div>
</>
)}
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface EndpointsSettingsData {
toRemove?: string[];
groupsToRemove?: string[];
}
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<EndpointsSettingsData>({
sectionName: 'endpoints',
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
// Common endpoint examples
const commonEndpoints = [
'img-to-pdf',
'pdf-to-img',
'merge-pdfs',
'split-pdf',
'rotate-pdf',
'compress-pdf',
'extract-images',
'extract-image-scans',
'add-watermark',
'remove-watermark',
'add-password',
'remove-password',
'change-permissions',
'ocr-pdf',
'pdf-to-pdfa',
'html-to-pdf',
'url-to-pdf',
'markdown-to-pdf',
'get-info-on-pdf',
'extract-pdf-metadata',
'pdf-to-single-page',
'crop',
'auto-split-pdf',
'sanitize-pdf',
'add-page-numbers',
'auto-rename',
'scale-pages',
'repair',
'flatten',
'remove-blanks',
'compare-pdfs'
];
// Common endpoint groups
const commonGroups = [
'Conversion',
'Security',
'Other',
'Organize',
'LibreOffice',
'CLI',
'Python',
'OpenCV'
];
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.endpoints.description', 'Control which API endpoints and endpoint groups are available.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.endpoints.management', 'Endpoint Management')}</Text>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}</span>
<PendingBadge show={isFieldPending('toRemove')} />
</Group>
}
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
value={settings.toRemove || []}
onChange={(value) => setSettings({ ...settings, toRemove: value })}
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}</span>
<PendingBadge show={isFieldPending('groupsToRemove')} />
</Group>
}
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
value={settings.groupsToRemove || []}
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
data={commonGroups.map(group => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<Paper bg="var(--mantine-color-blue-light)" p="sm" radius="sm">
<Text size="xs" c="dimmed">
{t('admin.settings.endpoints.note', 'Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect.')}
</Text>
</Paper>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,209 @@
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 '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
interface FeaturesSettingsData {
serverCertificate?: {
enabled?: boolean;
organizationName?: string;
validity?: number;
regenerateOnStartup?: boolean;
};
}
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
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 result: any = {
serverCertificate: systemData.serverCertificate || {
enabled: true,
organizationName: 'Stirling-PDF',
validity: 365,
regenerateOnStartup: false
}
};
// 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) {
deltaSettings['system.serverCertificate.enabled'] = settings.serverCertificate.enabled;
deltaSettings['system.serverCertificate.organizationName'] = settings.serverCertificate.organizationName;
deltaSettings['system.serverCertificate.validity'] = settings.serverCertificate.validity;
deltaSettings['system.serverCertificate.regenerateOnStartup'] = settings.serverCertificate.regenerateOnStartup;
}
return {
sectionData: {},
deltaSettings
};
}
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.features.description', 'Configure optional features and functionality.')}
</Text>
</div>
{/* Server Certificate - Pro Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate', 'Server Certificate')}</Text>
<Badge color="blue" size="sm">PRO</Badge>
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.features.serverCertificate.description', 'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality')}
</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled', 'Enable Server Certificate')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')}
</Text>
</div>
<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={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.organizationName', 'Organization Name')}</span>
<PendingBadge show={isFieldPending('serverCertificate.organizationName')} />
</Group>
}
description={t('admin.settings.features.serverCertificate.organizationName.description', 'Organization name for generated certificates')}
value={settings.serverCertificate?.organizationName || 'Stirling-PDF'}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
})}
placeholder="Stirling-PDF"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.validity', 'Certificate Validity (days)')}</span>
<PendingBadge show={isFieldPending('serverCertificate.validity')} />
</Group>
}
description={t('admin.settings.features.serverCertificate.validity.description', 'Number of days the certificate will be valid')}
value={settings.serverCertificate?.validity ?? 365}
onChange={(value) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, validity: Number(value) }
})}
min={1}
max={3650}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup', 'Regenerate on Startup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
</Text>
</div>
<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>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,526 @@
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 '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
interface GeneralSettingsData {
ui: {
appNameNavbar?: string;
languages?: string[];
};
system: {
defaultLocale?: string;
showUpdate?: boolean;
showUpdateOnlyAdmin?: boolean;
customHTMLFiles?: boolean;
fileUploadLimit?: string;
};
customPaths?: {
pipeline?: {
watchedFoldersDir?: string;
finishedFoldersDir?: string;
};
operations?: {
weasyprint?: string;
unoconvert?: string;
};
};
customMetadata?: {
autoUpdateMetadata?: boolean;
author?: string;
creator?: string;
producer?: string;
};
}
export default function AdminGeneralSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<GeneralSettingsData>({
sectionName: 'general',
fetchTransformer: async () => {
const [uiResponse, systemResponse, premiumResponse] = await Promise.all([
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.data || {};
const system = systemResponse.data || {};
const premium = premiumResponse.data || {};
const result: any = {
ui,
system,
customPaths: system.customPaths || {
pipeline: {
watchedFoldersDir: '',
finishedFoldersDir: ''
},
operations: {
weasyprint: '',
unoconvert: ''
}
},
customMetadata: premium.proFeatures?.customMetadata || {
autoUpdateMetadata: false,
author: '',
creator: '',
producer: ''
}
};
// 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;
}
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
};
if (settings.customPaths) {
deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths.pipeline?.watchedFoldersDir;
deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths.pipeline?.finishedFoldersDir;
deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths.operations?.weasyprint;
deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths.operations?.unoconvert;
}
return {
sectionData: {},
deltaSettings
};
}
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.general.description', 'Configure general application settings including branding and default behaviour.')}
</Text>
</div>
{/* System Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.system', 'System')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.appNameNavbar', 'Navbar Brand')}</span>
<PendingBadge show={isFieldPending('ui.appNameNavbar')} />
</Group>
}
description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')}
value={settings.ui.appNameNavbar || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })}
placeholder="Stirling PDF"
/>
</div>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.general.languages', 'Available Languages')}</span>
<PendingBadge show={isFieldPending('ui.languages')} />
</Group>
}
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
value={settings.ui.languages || []}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
data={[
{ value: 'de_DE', label: 'Deutsch' },
{ value: 'es_ES', label: 'Español' },
{ value: 'fr_FR', label: 'Français' },
{ value: 'it_IT', label: 'Italiano' },
{ value: 'pl_PL', label: 'Polski' },
{ value: 'pt_BR', label: 'Português (Brasil)' },
{ value: 'ru_RU', label: 'Русский' },
{ value: 'zh_CN', label: '简体中文' },
{ value: 'ja_JP', label: '日本語' },
{ value: 'ko_KR', label: '한국어' },
]}
searchable
clearable
placeholder="Select languages"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.defaultLocale', 'Default Locale')}</span>
<PendingBadge show={isFieldPending('system.defaultLocale')} />
</Group>
}
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
value={settings.system.defaultLocale || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
placeholder="en_US"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.fileUploadLimit', 'File Upload Limit')}</span>
<PendingBadge show={isFieldPending('system.fileUploadLimit')} />
</Group>
}
description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')}
value={settings.system.fileUploadLimit || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })}
placeholder="100MB"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate', 'Show Update Notifications')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdate.description', 'Display notifications when a new version is available')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdate || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('system.showUpdate')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin', 'Show Updates to Admins Only')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdateOnlyAdmin.description', 'Restrict update notifications to admin users only')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdateOnlyAdmin || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('system.showUpdateOnlyAdmin')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles', 'Custom HTML Files')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customHTMLFiles.description', 'Allow serving custom HTML files from the customFiles directory')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.system.customHTMLFiles || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('system.customHTMLFiles')} />
</Group>
</div>
</Stack>
</Paper>
{/* Custom Metadata - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata', 'Custom Metadata')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.customMetadata.autoUpdate', 'Auto Update Metadata')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customMetadata.autoUpdate.description', 'Automatically update PDF metadata on all processed documents')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.customMetadata?.autoUpdateMetadata || false}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
autoUpdateMetadata: e.target.checked
}
})}
/>
<PendingBadge show={isFieldPending('customMetadata.autoUpdateMetadata')} />
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.author', 'Default Author')}</span>
<PendingBadge show={isFieldPending('customMetadata.author')} />
</Group>
}
description={t('admin.settings.general.customMetadata.author.description', 'Default author for PDF metadata (e.g., username)')}
value={settings.customMetadata?.author || ''}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
author: e.target.value
}
})}
placeholder="username"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.creator', 'Default Creator')}</span>
<PendingBadge show={isFieldPending('customMetadata.creator')} />
</Group>
}
description={t('admin.settings.general.customMetadata.creator.description', 'Default creator for PDF metadata')}
value={settings.customMetadata?.creator || ''}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
creator: e.target.value
}
})}
placeholder="Stirling-PDF"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.producer', 'Default Producer')}</span>
<PendingBadge show={isFieldPending('customMetadata.producer')} />
</Group>
}
description={t('admin.settings.general.customMetadata.producer.description', 'Default producer for PDF metadata')}
value={settings.customMetadata?.producer || ''}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
producer: e.target.value
}
})}
placeholder="Stirling-PDF"
/>
</div>
</Stack>
</Paper>
{/* Custom Paths */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.customPaths', 'Custom Paths')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.general.customPaths.description', 'Configure custom file system paths for pipeline processing and external tools')}
</Text>
</div>
<Text fw={500} size="sm" mt="xs">{t('admin.settings.general.customPaths.pipeline', 'Pipeline Directories')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.watchedFoldersDir', 'Watched Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.watchedFoldersDir')} />
</Group>
}
description={t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.description', 'Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)')}
value={settings.customPaths?.pipeline?.watchedFoldersDir || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
pipeline: {
...settings.customPaths?.pipeline,
watchedFoldersDir: e.target.value
}
}
})}
placeholder="/pipeline/watchedFolders"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.finishedFoldersDir', 'Finished Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.finishedFoldersDir')} />
</Group>
}
description={t('admin.settings.general.customPaths.pipeline.finishedFoldersDir.description', 'Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)')}
value={settings.customPaths?.pipeline?.finishedFoldersDir || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
pipeline: {
...settings.customPaths?.pipeline,
finishedFoldersDir: e.target.value
}
}
})}
placeholder="/pipeline/finishedFolders"
/>
</div>
<Text fw={500} size="sm" mt="md">{t('admin.settings.general.customPaths.operations', 'External Tool Paths')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.weasyprint', 'WeasyPrint Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.weasyprint')} />
</Group>
}
description={t('admin.settings.general.customPaths.operations.weasyprint.description', 'Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)')}
value={settings.customPaths?.operations?.weasyprint || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
operations: {
...settings.customPaths?.operations,
weasyprint: e.target.value
}
}
})}
placeholder="/opt/venv/bin/weasyprint"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.unoconvert', 'Unoconvert Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.unoconvert')} />
</Group>
}
description={t('admin.settings.general.customPaths.operations.unoconvert.description', 'Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)')}
value={settings.customPaths?.operations?.unoconvert || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
operations: {
...settings.customPaths?.operations,
unoconvert: e.target.value
}
}
})}
placeholder="/opt/venv/bin/unoconvert"
/>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,177 @@
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 '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface LegalSettingsData {
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
export default function AdminLegalSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<LegalSettingsData>({
sectionName: 'legal',
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.legal.description', 'Configure links to legal documents and policies.')}
</Text>
</div>
{/* Legal Disclaimer */}
<Alert
icon={<WarningIcon style={{ fontSize: 18 }} />}
title={t('admin.settings.legal.disclaimer.title', 'Legal Responsibility Warning')}
color="yellow"
variant="light"
>
<Text size="sm">
{t(
'admin.settings.legal.disclaimer.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.'
)}
</Text>
</Alert>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}</span>
<PendingBadge show={isFieldPending('termsAndConditions')} />
</Group>
}
description={t('admin.settings.legal.termsAndConditions.description', 'URL or filename to terms and conditions')}
value={settings.termsAndConditions || ''}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}</span>
<PendingBadge show={isFieldPending('privacyPolicy')} />
</Group>
}
description={t('admin.settings.legal.privacyPolicy.description', 'URL or filename to privacy policy')}
value={settings.privacyPolicy || ''}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}</span>
<PendingBadge show={isFieldPending('accessibilityStatement')} />
</Group>
}
description={t('admin.settings.legal.accessibilityStatement.description', 'URL or filename to accessibility statement')}
value={settings.accessibilityStatement || ''}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}</span>
<PendingBadge show={isFieldPending('cookiePolicy')} />
</Group>
}
description={t('admin.settings.legal.cookiePolicy.description', 'URL or filename to cookie policy')}
value={settings.cookiePolicy || ''}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.impressum', 'Impressum')}</span>
<PendingBadge show={isFieldPending('impressum')} />
</Group>
}
description={t('admin.settings.legal.impressum.description', 'URL or filename to impressum (required in some jurisdictions)')}
value={settings.impressum || ''}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,195 @@
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 '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface MailSettingsData {
enabled?: boolean;
enableInvites?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
export default function AdminMailSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailSettingsData>({
sectionName: 'mail',
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.mail.title', 'Mail Configuration')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enabled', 'Enable Mail')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</Group>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enableInvites', 'Enable Email Invites')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableInvites || false}
onChange={(e) => setSettings({ ...settings, enableInvites: e.target.checked })}
disabled={!settings.enabled}
/>
<PendingBadge show={isFieldPending('enableInvites')} />
</Group>
</Group>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.host', 'SMTP Host')}</span>
<PendingBadge show={isFieldPending('host')} />
</Group>
}
description={t('admin.settings.mail.host.description', 'SMTP server hostname')}
value={settings.host || ''}
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
placeholder="smtp.example.com"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.port', 'SMTP Port')}</span>
<PendingBadge show={isFieldPending('port')} />
</Group>
}
description={t('admin.settings.mail.port.description', 'SMTP server port (typically 587 for TLS, 465 for SSL)')}
value={settings.port || 587}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.username', 'SMTP Username')}</span>
<PendingBadge show={isFieldPending('username')} />
</Group>
}
description={t('admin.settings.mail.username.description', 'SMTP authentication username')}
value={settings.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
/>
</div>
<div>
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.password', 'SMTP Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
}
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
value={settings.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.from', 'From Address')}</span>
<PendingBadge show={isFieldPending('from')} />
</Group>
}
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="[email protected]"
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,136 @@
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 '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.premium.description', 'Configure your premium or enterprise license key.')}
</Text>
</div>
{/* Notice about moved features */}
<Alert
variant="light"
color="blue"
title={t('admin.settings.premium.movedFeatures.title', 'Premium Features Distributed')}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.movedFeatures.message', 'Premium and Enterprise features are now organized in their respective sections:')}
</Text>
<ul style={{ marginTop: '8px', marginBottom: 0, paddingLeft: '20px' }}>
<li><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></li>
<li><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></li>
<li><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></li>
<li><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></li>
</ul>
</Alert>
{/* License Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License Configuration')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key')}
value={settings.key || ''}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.enabled', 'Enable Premium Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,189 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
interface PrivacySettingsData {
enableAnalytics?: boolean;
googleVisibility?: boolean;
metricsEnabled?: boolean;
}
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PrivacySettingsData>({
sectionName: 'privacy',
fetchTransformer: async () => {
const [metricsResponse, systemResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/metrics'),
apiClient.get('/api/v1/admin/settings/section/system')
]);
const metrics = metricsResponse.data;
const system = systemResponse.data;
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;
}
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
};
return {
sectionData: {},
deltaSettings
};
}
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.privacy.title', 'Privacy')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.privacy.description', 'Configure privacy and data collection settings.')}
</Text>
</div>
{/* Analytics & Tracking */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.analytics', 'Analytics & Tracking')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics', 'Enable Analytics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')}
</Text>
</div>
<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' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled', 'Enable Metrics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.metricsEnabled || false}
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('metricsEnabled')} />
</Group>
</div>
</Stack>
</Paper>
{/* Search Engine Visibility */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.searchEngine', 'Search Engine Visibility')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility', 'Google Visibility')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.googleVisibility || false}
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
/>
<PendingBadge show={isFieldPending('googleVisibility')} />
</Group>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -0,0 +1,618 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, Alert, Badge, Accordion, Textarea } from '@mantine/core';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
interface SecuritySettingsData {
enableLogin?: boolean;
csrfDisabled?: boolean;
loginMethod?: string;
loginAttemptCount?: number;
loginResetTimeMinutes?: number;
jwt?: {
persistence?: boolean;
enableKeyRotation?: boolean;
enableKeyCleanup?: boolean;
keyRetentionDays?: number;
secureCookie?: boolean;
};
audit?: {
enabled?: boolean;
level?: number;
retentionDays?: number;
};
html?: {
urlSecurity?: {
enabled?: boolean;
level?: string;
allowedDomains?: string[];
blockedDomains?: string[];
internalTlds?: string[];
blockPrivateNetworks?: boolean;
blockLocalhost?: boolean;
blockLinkLocal?: boolean;
blockCloudMetadata?: boolean;
};
};
}
export default function AdminSecuritySection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
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 securityData = securityResponse.data || {};
const premiumData = premiumResponse.data || {};
const systemData = systemResponse.data || {};
const { _pending: securityPending, ...securityActive } = securityData;
const { _pending: premiumPending, ...premiumActive } = premiumData;
const { _pending: systemPending, ...systemActive } = systemData;
const combined: any = {
...securityActive,
audit: premiumActive.enterpriseFeatures?.audit || {
enabled: false,
level: 2,
retentionDays: 90
},
html: systemActive.html || {
urlSecurity: {
enabled: true,
level: 'MEDIUM',
allowedDomains: [],
blockedDomains: [],
internalTlds: ['.local', '.internal', '.corp', '.home'],
blockPrivateNetworks: true,
blockLocalhost: true,
blockLinkLocal: true,
blockCloudMetadata: true
}
}
};
// 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 deltaSettings: Record<string, any> = {
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
'premium.enterpriseFeatures.audit.level': audit?.level,
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
};
if (html?.urlSecurity) {
deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled;
deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level;
deltaSettings['system.html.urlSecurity.allowedDomains'] = html.urlSecurity.allowedDomains;
deltaSettings['system.html.urlSecurity.blockedDomains'] = html.urlSecurity.blockedDomains;
deltaSettings['system.html.urlSecurity.internalTlds'] = html.urlSecurity.internalTlds;
deltaSettings['system.html.urlSecurity.blockPrivateNetworks'] = html.urlSecurity.blockPrivateNetworks;
deltaSettings['system.html.urlSecurity.blockLocalhost'] = html.urlSecurity.blockLocalhost;
deltaSettings['system.html.urlSecurity.blockLinkLocal'] = html.urlSecurity.blockLinkLocal;
deltaSettings['system.html.urlSecurity.blockCloudMetadata'] = html.urlSecurity.blockCloudMetadata;
}
return {
sectionData: securitySettings,
deltaSettings
};
}
});
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'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.security.description', 'Configure authentication, login behaviour, and security policies.')}
</Text>
</div>
{/* Authentication Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.authentication', 'Authentication')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.enableLogin', 'Enable Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableLogin || false}
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enableLogin')} />
</Group>
</div>
<div>
<Select
label={t('admin.settings.security.loginMethod', 'Login Method')}
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
value={settings.loginMethod || 'all'}
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
data={[
{ value: 'all', label: t('admin.settings.security.loginMethod.all', 'All Methods') },
{ value: 'normal', label: t('admin.settings.security.loginMethod.normal', 'Username/Password Only') },
{ value: 'oauth2', label: t('admin.settings.security.loginMethod.oauth2', 'OAuth2 Only') },
{ value: 'saml2', label: t('admin.settings.security.loginMethod.saml2', 'SAML2 Only') },
]}
comboboxProps={{ zIndex: 1400 }}
/>
{isFieldPending('loginMethod') && (
<Group mt="xs">
<PendingBadge show={true} />
</Group>
)}
</div>
<div>
<NumberInput
label={t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
value={settings.loginAttemptCount || 0}
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
min={0}
max={100}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
value={settings.loginResetTimeMinutes || 0}
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
min={0}
max={1440}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled', 'Disable CSRF Protection')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.csrfDisabled || false}
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('csrfDisabled')} />
</Group>
</div>
</Stack>
</Paper>
{/* SSO/SAML Notice */}
<Alert
variant="light"
color="blue"
title={t('admin.settings.security.ssoNotice.title', 'Looking for SSO/SAML settings?')}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.security.ssoNotice.message', 'OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management.')}
</Text>
</Alert>
{/* JWT Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt', 'JWT Configuration')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence', 'Enable Key Persistence')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.persistence')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation', 'Enable Key Rotation')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup', 'Enable Key Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
min={1}
max={365}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie', 'Secure Cookie')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.secureCookie')} />
</Group>
</div>
</Stack>
</Paper>
{/* Audit Logging - Enterprise Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.security.audit', 'Audit Logging')}</Text>
<Badge color="grape" size="sm">ENTERPRISE</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.audit.enabled', 'Enable Audit Logging')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.audit.enabled.description', 'Track user actions and system events for compliance and security monitoring')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('audit.enabled')} />
</Group>
</div>
<div>
<NumberInput
label={t('admin.settings.security.audit.level', 'Audit Level')}
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
value={settings.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
min={0}
max={3}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
value={settings.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
min={1}
max={3650}
/>
</div>
</Stack>
</Paper>
{/* HTML URL Security */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.htmlUrlSecurity', 'HTML URL Security')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.security.htmlUrlSecurity.description', 'Configure URL access restrictions for HTML processing to prevent SSRF attacks')}
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.enabled', 'Enable URL Security')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')}
</Text>
</div>
<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('html.urlSecurity.enabled')} />
</Group>
</div>
<div>
<Select
label={t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
onChange={(value) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, level: value || 'MEDIUM' }
}
})}
data={[
{ value: 'MAX', label: t('admin.settings.security.htmlUrlSecurity.level.max', 'Maximum (Whitelist Only)') },
{ value: 'MEDIUM', label: t('admin.settings.security.htmlUrlSecurity.level.medium', 'Medium (Block Internal)') },
{ value: 'OFF', label: t('admin.settings.security.htmlUrlSecurity.level.off', 'Off (No Restrictions)') },
]}
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<Accordion variant="separated">
<Accordion.Item value="advanced">
<Accordion.Control>{t('admin.settings.security.htmlUrlSecurity.advanced', 'Advanced Settings')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
{/* Allowed Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: {
...settings.html?.urlSecurity,
allowedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
})}
placeholder="cdn.example.com&#10;images.google.com"
minRows={3}
autosize
/>
</div>
{/* Blocked Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: {
...settings.html?.urlSecurity,
blockedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
})}
placeholder="malicious.com&#10;evil.org"
minRows={3}
autosize
/>
</div>
{/* Internal TLDs */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: {
...settings.html?.urlSecurity,
internalTlds: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
})}
placeholder=".local&#10;.internal&#10;.corp&#10;.home"
minRows={3}
autosize
/>
</div>
{/* Network Blocking Options */}
<Text fw={600} size="sm" mt="md">{t('admin.settings.security.htmlUrlSecurity.networkBlocking', 'Network Blocking')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks', 'Block Private Networks')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost', 'Block Localhost')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal', 'Block Link-Local Addresses')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata', 'Block Cloud Metadata Endpoints')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
/>
</div>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}
@@ -35,7 +35,6 @@ const Overview: React.FC = () => {
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
@@ -0,0 +1,673 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Text,
Button,
TextInput,
Table,
ActionIcon,
Menu,
Badge,
Loader,
Group,
Modal,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService, User } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct');
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
forceChange: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
if (config) {
console.log('[PeopleSection] Email invites enabled:', config.enableEmailInvites);
}
}, [config]);
const fetchData = async () => {
try {
setLoading(true);
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
}));
setUsers(enrichedUsers);
setTeams(teamsData);
} catch (error) {
console.error('Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
} finally {
setLoading(false);
}
};
const handleInviteUser = async () => {
if (!inviteForm.username || !inviteForm.password) {
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: 'password',
forceChange: inviteForm.forceChange,
});
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
setInviteModalOpened(false);
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
fetchData();
} catch (error: any) {
console.error('Failed to create user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.addMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'At least one email address is required') });
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails,
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', `${response.successCount} user(s) invited successfully`)
});
if (response.failureCount > 0 && response.errors) {
alert({
alertType: 'warning',
title: t('workspace.people.emailInvite.partialSuccess', 'Some invites failed'),
body: response.errors
});
}
setInviteModalOpened(false);
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
fetchData();
} else {
alert({
alertType: 'error',
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
body: response.errors || response.error
});
}
} catch (error: any) {
console.error('Failed to invite users:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.emailInvite.error', 'Failed to send invites');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUpdateUserRole = async () => {
if (!selectedUser) return;
try {
setProcessing(true);
await userManagementService.updateUserRole({
username: selectedUser.username,
role: editForm.role,
teamId: editForm.teamId,
});
alert({ alertType: 'success', title: t('workspace.people.editMember.success') });
closeEditModal();
fetchData();
} catch (error: any) {
console.error('Failed to update user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.editMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleToggleEnabled = async (user: User) => {
try {
await userManagementService.toggleUserEnabled(user.username, !user.enabled);
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
fetchData();
} catch (error: any) {
console.error('Failed to toggle user status:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.toggleEnabled.error');
alert({ alertType: 'error', title: errorMessage });
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
await userManagementService.deleteUser(user.username);
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
fetchData();
} catch (error: any) {
console.error('Failed to delete user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.deleteUserError', 'Failed to delete user');
alert({ alertType: 'error', title: errorMessage });
}
};
const openEditModal = (user: User) => {
setSelectedUser(user);
setEditForm({
role: user.roleName,
teamId: user.team?.id,
});
setEditUserModalOpened(true);
};
const closeEditModal = () => {
setEditUserModalOpened(false);
setSelectedUser(null);
setEditForm({
role: 'ROLE_USER',
teamId: undefined,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
const roleOptions = [
{
value: 'ROLE_ADMIN',
label: t('workspace.people.admin'),
description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'),
icon: 'admin-panel-settings'
},
{
value: 'ROLE_USER',
label: t('workspace.people.member'),
description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'),
icon: 'person'
},
];
const renderRoleOption = ({ option }: { option: any }) => (
<Group gap="sm" wrap="nowrap">
<LocalIcon icon={option.icon} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>{option.label}</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
{option.description}
</Text>
</div>
</Group>
);
const teamOptions = teams.map((team) => ({
value: team.id.toString(),
label: team.name,
}));
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.people.loading', 'Loading people...')}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">
{t('workspace.people.title')}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.people.description')}
</Text>
</div>
{/* Header Actions */}
<Group justify="space-between">
<TextInput
placeholder={t('workspace.people.searchMembers')}
leftSection={<LocalIcon icon="search" width="1rem" height="1rem" />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
style={{ maxWidth: 300 }}
/>
<Button leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />} onClick={() => setInviteModalOpened(true)}>
{t('workspace.people.addMembers')}
</Button>
</Group>
{/* Members Table */}
<Paper withBorder p="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('workspace.people.user')}</Table.Th>
<Table.Th>{t('workspace.people.role')}</Table.Th>
<Table.Th>{t('workspace.people.team')}</Table.Th>
<Table.Th>{t('workspace.people.status')}</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredUsers.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.people.noMembersFound')}
</Text>
</Table.Td>
</Table.Tr>
) : (
filteredUsers.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{user.isActive && (
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-green-6)',
flexShrink: 0,
}}
title={t('workspace.people.activeSession', 'Active session')}
/>
)}
<div>
<Text size="sm" fw={500}>
{user.username}
</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</div>
</div>
</Table.Td>
<Table.Td>
<Badge
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
variant="light"
>
{(user.rolesAsString || '').includes('ROLE_ADMIN') ? t('workspace.people.admin') : t('workspace.people.member')}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{user.team?.name || '—'}</Text>
</Table.Td>
<Table.Td>
<Badge color={user.enabled ? 'green' : 'red'} variant="light">
{user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
<Text size="xs">
Last Activity: {user.lastRequest
? new Date(user.lastRequest).toLocaleString()
: 'Never'}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item onClick={() => openEditModal(user)}>{t('workspace.people.editRole')}</Menu.Item>
<Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
onClick={() => handleToggleEnabled(user)}
>
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)}>
{t('workspace.people.deleteUser')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
{/* Add Member Modal */}
<Modal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setInviteModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => setInviteMode(value as 'email' | 'direct')}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', '[email protected], [email protected]')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Edit User Modal */}
<Modal
opened={editUserModalOpened}
onClose={closeEditModal}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={closeEditModal}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.editMember.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.people.editMember.editing')} <strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t('workspace.people.editMember.role')}
data={roleOptions}
value={editForm.role}
onChange={(value) => setEditForm({ ...editForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.editMember.team')}
placeholder={t('workspace.people.editMember.teamPlaceholder')}
data={teamOptions}
value={editForm.teamId?.toString()}
onChange={(value) => setEditForm({ ...editForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
{t('workspace.people.editMember.submit')}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}
@@ -0,0 +1,190 @@
import { useState } from 'react';
import { Paper, Group, Text, Button, Collapse, Stack, TextInput, Textarea, Switch, PasswordInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions';
interface ProviderCardProps {
provider: Provider;
isConfigured: boolean;
settings?: Record<string, any>;
onSave?: (settings: Record<string, any>) => void;
onDisconnect?: () => void;
}
export default function ProviderCard({
provider,
isConfigured,
settings = {},
onSave,
onDisconnect,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
// Initialize local settings with defaults when opening an unconfigured provider
const handleConnectToggle = () => {
if (!isConfigured && !expanded) {
// First time opening an unconfigured provider - initialize with defaults
const defaultSettings: Record<string, any> = {};
provider.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultSettings[field.key] = field.defaultValue;
}
});
setLocalSettings(defaultSettings);
}
setExpanded(!expanded);
};
const handleFieldChange = (key: string, value: any) => {
setLocalSettings((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
if (onSave) {
onSave(localSettings);
}
setExpanded(false);
};
const renderField = (field: ProviderField) => {
const value = localSettings[field.key] ?? field.defaultValue ?? '';
switch (field.type) {
case 'switch':
return (
<div key={field.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{field.label}</Text>
<Text size="xs" c="dimmed" mt={4}>{field.description}</Text>
</div>
<Switch
checked={value || false}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
/>
</div>
);
case 'password':
return (
<PasswordInput
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
);
case 'textarea':
return (
<Textarea
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
);
default:
return (
<TextInput
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
);
}
};
const renderProviderIcon = () => {
// If icon starts with '/', it's a path to an SVG file
if (provider.icon.startsWith('/')) {
return (
<img
src={provider.icon}
alt={provider.name}
style={{ width: '1.5rem', height: '1.5rem' }}
/>
);
}
// Otherwise use LocalIcon for iconify icons
return <LocalIcon icon={provider.icon} width="1.5rem" height="1.5rem" />;
};
return (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{/* Provider Header */}
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
{renderProviderIcon()}
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="sm">{provider.name}</Text>
<Text size="xs" c="dimmed" truncate>{provider.scope}</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
variant={isConfigured ? "subtle" : "filled"}
size="xs"
onClick={isConfigured ? () => setExpanded(!expanded) : handleConnectToggle}
rightSection={
expanded ? (
<LocalIcon
icon="close-rounded"
width="1rem"
height="1rem"
/>
) : (isConfigured ? (
<LocalIcon
icon="expand-more-rounded"
width="1rem"
height="1rem"
/>
) : undefined)
}
>
{isConfigured
? (expanded ? t('admin.close', 'Close') : t('admin.expand', 'Expand'))
: (expanded ? t('admin.close', 'Close') : t('admin.settings.connections.connect', 'Connect'))
}
</Button>
</Group>
</Group>
{/* Expandable Settings */}
<Collapse in={expanded}>
<Stack gap="md" mt="xs">
{provider.fields.map((field) => renderField(field))}
<Group justify="flex-end" mt="sm">
{onDisconnect && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onDisconnect}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
<Button size="sm" onClick={handleSave}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Collapse>
</Stack>
</Paper>
);
}
@@ -0,0 +1,476 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Text,
Button,
Table,
ActionIcon,
Badge,
Loader,
Group,
Modal,
Select,
Paper,
CloseButton,
Tooltip,
Menu,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { User, userManagementService } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface TeamDetailsSectionProps {
teamId: number;
onBack: () => void;
}
export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectionProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [team, setTeam] = useState<Team | null>(null);
const [teamUsers, setTeamUsers] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [userLastRequest, setUserLastRequest] = useState<Record<string, number>>({});
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
const [processing, setProcessing] = useState(false);
useEffect(() => {
fetchTeamDetails();
fetchAllTeams();
}, [teamId]);
const fetchTeamDetails = async () => {
try {
setLoading(true);
const data = await teamService.getTeamDetails(teamId);
console.log('[TeamDetailsSection] Raw data:', data);
setTeam(data.team);
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
setAvailableUsers(Array.isArray(data.availableUsers) ? data.availableUsers : []);
setUserLastRequest(data.userLastRequest || {});
} catch (error) {
console.error('Failed to fetch team details:', error);
alert({ alertType: 'error', title: 'Failed to load team details' });
onBack();
} finally {
setLoading(false);
}
};
const fetchAllTeams = async () => {
try {
const teams = await teamService.getTeams();
setAllTeams(teams);
} catch (error) {
console.error('Failed to fetch teams:', error);
}
};
const handleAddMember = async () => {
if (!selectedUserId) {
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.selectUserRequired', 'Please select a user') });
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success', 'User added to team successfully') });
setAddMemberModalOpened(false);
setSelectedUserId('');
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to add member:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.addMemberToTeam.error', 'Failed to add user to team');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRemoveMember = async (user: User) => {
if (!window.confirm(t('workspace.teams.confirmRemove', `Remove ${user.username} from this team?`))) {
return;
}
try {
setProcessing(true);
// Find the Default team ID
const defaultTeam = allTeams.find(t => t.name === 'Default');
if (!defaultTeam) {
throw new Error('Default team not found');
}
// Move user to Default team by updating their role with the Default team ID
await teamService.moveUserToTeam(user.username, user.rolesAsString || 'ROLE_USER', defaultTeam.id);
alert({ alertType: 'success', title: t('workspace.teams.removeMemberSuccess', 'User removed from team') });
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to remove member:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.removeMemberError', 'Failed to remove user from team');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
setProcessing(true);
await userManagementService.deleteUser(user.username);
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to delete user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.deleteUserError', 'Failed to delete user');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const openChangeTeamModal = (user: User) => {
setSelectedUser(user);
setSelectedTeamId(user.team?.id?.toString() || '');
setChangeTeamModalOpened(true);
};
const handleChangeTeam = async () => {
if (!selectedUser || !selectedTeamId) {
alert({ alertType: 'error', title: t('workspace.teams.changeTeam.selectTeamRequired', 'Please select a team') });
return;
}
try {
setProcessing(true);
await teamService.moveUserToTeam(selectedUser.username, selectedUser.rolesAsString || 'ROLE_USER', parseInt(selectedTeamId));
alert({ alertType: 'success', title: t('workspace.teams.changeTeam.success', 'Team changed successfully') });
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId('');
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to change team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.changeTeam.error', 'Failed to change team');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
if (loading) {
return (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loadingDetails', 'Loading team details...')}
</Text>
</Stack>
);
}
if (!team) {
return (
<Stack align="center" py="xl">
<Text size="sm" c="red">
{t('workspace.teams.teamNotFound', 'Team not found')}
</Text>
<Button variant="light" onClick={onBack}>
{t('workspace.teams.backToTeams', 'Back to Teams')}
</Button>
</Stack>
);
}
return (
<Stack gap="lg">
{/* Header with back button */}
<Group>
<ActionIcon variant="subtle" onClick={onBack}>
<LocalIcon icon="arrow-back" width="1.2rem" height="1.2rem" />
</ActionIcon>
<div style={{ flex: 1 }}>
<Text fw={600} size="lg">
{team.name}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.memberCount', { count: teamUsers.length })} {teamUsers.length === 1 ? 'member' : 'members'}
</Text>
</div>
</Group>
{/* Add Member Button */}
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setAddMemberModalOpened(true)}
disabled={team.name === 'Internal'}
>
{t('workspace.teams.addMember')}
</Button>
</Group>
{/* Members Table */}
<Paper withBorder p="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('workspace.people.user')}</Table.Th>
<Table.Th>{t('workspace.people.role')}</Table.Th>
<Table.Th>{t('workspace.people.status')}</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamUsers.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.teams.noMembers', 'No members in this team')}
</Text>
</Table.Td>
</Table.Tr>
) : (
teamUsers.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>
<div>
<Text size="sm" fw={500}>
{user.username}
</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</div>
</Table.Td>
<Table.Td>
<Badge
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
variant="light"
>
{(user.rolesAsString || '').includes('ROLE_ADMIN')
? t('workspace.people.admin')
: t('workspace.people.member')}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={user.enabled ? 'green' : 'red'} variant="light">
{user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>
Authentication: {user.authenticationType || 'Unknown'}
</Text>
<Text size="xs">
Last Activity:{' '}
{userLastRequest[user.username]
? new Date(userLastRequest[user.username]).toLocaleString()
: 'Never'}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item
leftSection={<LocalIcon icon="swap-horiz" width="1rem" height="1rem" />}
onClick={() => openChangeTeamModal(user)}
disabled={processing || team.name === 'Internal'}
>
{t('workspace.teams.changeTeam.label', 'Change Team')}
</Menu.Item>
{team.name !== 'Internal' && team.name !== 'Default' && (
<Menu.Item
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
onClick={() => handleRemoveMember(user)}
disabled={processing}
>
{t('workspace.teams.removeMember', 'Remove from team')}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteUser(user)}
disabled={processing || team.name === 'Internal'}
>
{t('workspace.people.deleteUser', 'Delete User')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.addMemberToTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{team.name}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.addMemberToTeam.selectUser')}
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
<Text size="xs" c="orange">
{t('workspace.teams.addMemberToTeam.willBeMoved')}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Change Team Modal */}
<Modal
opened={changeTeamModalOpened}
onClose={() => setChangeTeamModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setChangeTeamModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="swap-horiz" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.changeTeam.title', 'Change Team')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.changeTeam.changing', 'Moving')} <strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.changeTeam.selectTeam', 'Select Team')}
placeholder={t('workspace.teams.changeTeam.selectTeamPlaceholder', 'Choose a team')}
data={allTeams
.filter((t) => t.name !== 'Internal')
.map((team) => ({
value: team.id.toString(),
label: team.name,
}))}
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value || '')}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleChangeTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.changeTeam.submit', 'Change Team')}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}
@@ -0,0 +1,456 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Text,
Button,
TextInput,
Table,
ActionIcon,
Menu,
Badge,
Loader,
Group,
Modal,
Paper,
Select,
CloseButton,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { userManagementService, User } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection';
export default function TeamsSection() {
const { t } = useTranslation();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false);
const [renameModalOpened, setRenameModalOpened] = useState(false);
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [processing, setProcessing] = useState(false);
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
const [newTeamName, setNewTeamName] = useState('');
const [renameTeamName, setRenameTeamName] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string>('');
useEffect(() => {
fetchTeams();
}, []);
const fetchTeams = async () => {
try {
setLoading(true);
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} catch (error) {
console.error('Failed to fetch teams:', error);
alert({ alertType: 'error', title: 'Failed to load teams' });
} finally {
setLoading(false);
}
};
const handleCreateTeam = async () => {
if (!newTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.createTeam.nameRequired') });
return;
}
try {
setProcessing(true);
await teamService.createTeam(newTeamName);
alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') });
setCreateModalOpened(false);
setNewTeamName('');
fetchTeams();
} catch (error: any) {
console.error('Failed to create team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.createTeam.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRenameTeam = async () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.renameTeam.nameRequired') });
return;
}
try {
setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') });
setRenameModalOpened(false);
setSelectedTeam(null);
setRenameTeamName('');
fetchTeams();
} catch (error: any) {
console.error('Failed to rename team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.renameTeam.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteTeam = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotDeleteInternal') });
return;
}
if (!confirm(t('workspace.teams.confirmDelete'))) {
return;
}
try {
await teamService.deleteTeam(team.id);
alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') });
fetchTeams();
} catch (error: any) {
console.error('Failed to delete team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.deleteTeam.error');
alert({ alertType: 'error', title: errorMessage });
}
};
const openRenameModal = (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotRenameInternal') });
return;
}
setSelectedTeam(team);
setRenameTeamName(team.name);
setRenameModalOpened(true);
};
const openAddMemberModal = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotAddToInternal') });
return;
}
setSelectedTeam(team);
try {
// Fetch all users to show in dropdown
const adminData = await userManagementService.getUsers();
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
console.error('Failed to fetch users:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
}
};
const handleAddMember = async () => {
if (!selectedTeam || !selectedUserId) {
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.userRequired') });
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId));
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') });
setAddMemberModalOpened(false);
setSelectedTeam(null);
setSelectedUserId('');
fetchTeams();
} catch (error) {
console.error('Failed to add member to team:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
} finally {
setProcessing(false);
}
};
// If viewing team details, render TeamDetailsSection
if (viewingTeamId !== null) {
return (
<TeamDetailsSection
teamId={viewingTeamId}
onBack={() => {
setViewingTeamId(null);
fetchTeams(); // Refresh teams list
}}
/>
);
}
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loading', 'Loading teams...')}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">
{t('workspace.teams.title')}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.description')}
</Text>
</div>
{/* Header Actions */}
<Group justify="flex-end">
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)}>
{t('workspace.teams.createNewTeam')}
</Button>
</Group>
{/* Teams Table */}
<Paper withBorder p="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('workspace.teams.teamName')}</Table.Th>
<Table.Th>{t('workspace.teams.totalMembers')}</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teams.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.teams.noTeamsFound')}
</Text>
</Table.Td>
</Table.Tr>
) : (
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: 'pointer' }}
onClick={() => setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
<Text size="sm" fw={500}>
{team.name}
</Text>
{team.name === 'Internal' && (
<Badge size="xs" color="gray">
{t('workspace.teams.system')}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{team.userCount || 0}</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)}>
{t('workspace.teams.viewTeam', 'View Team')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)}>
{t('workspace.teams.addMember')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)}>
{t('workspace.teams.renameTeamLabel')}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)}
disabled={team.name === 'Internal'}
>
{t('workspace.teams.deleteTeamLabel')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
{/* Create Team Modal */}
<Modal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setCreateModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="group-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.createTeam.title')}
</Text>
</Stack>
<TextInput
label={t('workspace.teams.createTeam.teamName')}
placeholder={t('workspace.teams.createTeam.teamNamePlaceholder')}
value={newTeamName}
onChange={(e) => setNewTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleCreateTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.createTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Rename Team Modal */}
<Modal
opened={renameModalOpened}
onClose={() => setRenameModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setRenameModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.renameTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.renameTeam.renaming')} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<TextInput
label={t('workspace.teams.renameTeam.newTeamName')}
placeholder={t('workspace.teams.renameTeam.newTeamNamePlaceholder')}
value={renameTeamName}
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleRenameTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.renameTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.addMemberToTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.addMemberToTeam.selectUser')}
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
<Text size="xs" c="orange">
{t('workspace.teams.addMemberToTeam.willBeMoved')}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}
@@ -0,0 +1,353 @@
export type ProviderType = 'oauth2' | 'saml2';
export interface ProviderField {
key: string;
type: 'text' | 'password' | 'switch' | 'textarea';
label: string;
description: string;
placeholder?: string;
defaultValue?: any;
}
export interface Provider {
id: string;
name: string;
icon: string;
type: ProviderType;
scope: string; // Summary of what this provider does
businessTier?: boolean; // Enterprise only
fields: ProviderField[];
}
export const OAUTH2_PROVIDERS: Provider[] = [
{
id: 'google',
name: 'Google',
icon: '/Login/google.svg',
type: 'oauth2',
scope: 'Sign-in authentication',
fields: [
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from Google Cloud Console',
placeholder: 'your-client-id.apps.googleusercontent.com',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from Google Cloud Console',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'email, profile',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username (email, name, given_name, family_name)',
defaultValue: 'email',
},
],
},
{
id: 'github',
name: 'GitHub',
icon: '/Login/github.svg',
type: 'oauth2',
scope: 'Sign-in authentication',
fields: [
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from GitHub Developer Settings',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from GitHub Developer Settings',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'read:user',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username (email, login, name)',
defaultValue: 'login',
},
],
},
{
id: 'keycloak',
name: 'Keycloak',
icon: 'key-rounded',
type: 'oauth2',
scope: 'SSO',
fields: [
{
key: 'issuer',
type: 'text',
label: 'Issuer URL',
description: "URL of the Keycloak realm's OpenID Connect Discovery endpoint",
placeholder: 'https://keycloak.example.com/realms/myrealm',
},
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from Keycloak',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from Keycloak',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'openid, profile, email',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username (email, name, given_name, family_name, preferred_username)',
defaultValue: 'preferred_username',
},
],
},
];
export const GENERIC_OAUTH2_PROVIDER: Provider = {
id: 'oauth2-generic',
name: 'Generic OAuth2',
icon: 'link-rounded',
type: 'oauth2',
scope: 'SSO',
fields: [
{
key: 'enabled',
type: 'switch',
label: 'Enable Generic OAuth2',
description: 'Enable authentication using a custom OAuth2 provider',
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: 'Provider Name',
description: 'The name of your OAuth2 provider (e.g., Azure AD, Okta)',
placeholder: 'azure-ad',
},
{
key: 'issuer',
type: 'text',
label: 'Issuer URL',
description: 'Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration)',
placeholder: 'https://login.microsoftonline.com/{tenant-id}/v2.0',
},
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from your provider',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from your provider',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'openid, profile, email',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username',
defaultValue: 'email',
},
{
key: 'autoCreateUser',
type: 'switch',
label: 'Auto Create Users',
description: 'Automatically create user accounts on first OAuth2 login',
defaultValue: true,
},
{
key: 'blockRegistration',
type: 'switch',
label: 'Block Registration',
description: 'Prevent new user registration via OAuth2',
defaultValue: false,
},
],
};
export const SMTP_PROVIDER: Provider = {
id: 'smtp',
name: 'SMTP Mail',
icon: 'mail-rounded',
type: 'oauth2', // Using oauth2 as the base type, but it's really just a generic provider
scope: 'Email Notifications',
fields: [
{
key: 'enabled',
type: 'switch',
label: 'Enable Mail',
description: 'Enable email notifications and SMTP functionality',
defaultValue: false,
},
{
key: 'host',
type: 'text',
label: 'SMTP Host',
description: 'The hostname or IP address of your SMTP server',
placeholder: 'smtp.example.com',
},
{
key: 'port',
type: 'text',
label: 'SMTP Port',
description: 'The port number for SMTP connection (typically 25, 465, or 587)',
placeholder: '587',
defaultValue: '587',
},
{
key: 'username',
type: 'text',
label: 'SMTP Username',
description: 'Username for SMTP authentication',
},
{
key: 'password',
type: 'password',
label: 'SMTP Password',
description: 'Password for SMTP authentication',
},
{
key: 'from',
type: 'text',
label: 'From Address',
description: 'The email address to use as the sender',
placeholder: '[email protected]',
},
],
};
export const SAML2_PROVIDER: Provider = {
id: 'saml2',
name: 'SAML2',
icon: 'verified-user-rounded',
type: 'saml2',
scope: 'SSO',
businessTier: true,
fields: [
{
key: 'enabled',
type: 'switch',
label: 'Enable SAML2',
description: 'Enable SAML2 authentication (Enterprise only)',
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: 'Provider Name',
description: 'The name of your SAML2 provider',
},
{
key: 'registrationId',
type: 'text',
label: 'Registration ID',
description: 'The name of your Service Provider (SP) app name',
defaultValue: 'stirling',
},
{
key: 'idpMetadataUri',
type: 'text',
label: 'IDP Metadata URI',
description: 'The URI for your provider\'s metadata',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata',
},
{
key: 'idpSingleLoginUrl',
type: 'text',
label: 'IDP Single Login URL',
description: 'The URL for initiating SSO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml',
},
{
key: 'idpSingleLogoutUrl',
type: 'text',
label: 'IDP Single Logout URL',
description: 'The URL for initiating SLO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml',
},
{
key: 'idpIssuer',
type: 'text',
label: 'IDP Issuer',
description: 'The ID of your provider',
},
{
key: 'idpCert',
type: 'text',
label: 'IDP Certificate',
description: 'The certificate path (e.g., classpath:okta.cert)',
placeholder: 'classpath:okta.cert',
},
{
key: 'privateKey',
type: 'text',
label: 'Private Key',
description: 'Your private key path',
placeholder: 'classpath:saml-private-key.key',
},
{
key: 'spCert',
type: 'text',
label: 'SP Certificate',
description: 'Your signing certificate path',
placeholder: 'classpath:saml-public-cert.crt',
},
{
key: 'autoCreateUser',
type: 'switch',
label: 'Auto Create Users',
description: 'Automatically create user accounts on first SAML2 login',
defaultValue: true,
},
{
key: 'blockRegistration',
type: 'switch',
label: 'Block Registration',
description: 'Prevent new user registration via SAML2',
defaultValue: false,
},
],
};
export const ALL_PROVIDERS = [...OAUTH2_PROVIDERS, GENERIC_OAUTH2_PROVIDER, SAML2_PROVIDER, SMTP_PROVIDER];
@@ -13,7 +13,17 @@ export type NavKey =
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys';
| 'hotkeys'
| 'adminGeneral'
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminDatabase'
| 'adminAdvanced'
| 'adminLegal'
| 'adminPremium'
| 'adminFeatures'
| 'adminEndpoints';
// some of these are not used yet, but appear in figma designs
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
export function useRestartServer() {
const { t } = useTranslation();
const [restartModalOpened, setRestartModalOpened] = useState(false);
const showRestartModal = () => {
setRestartModalOpened(true);
};
const closeRestartModal = () => {
setRestartModalOpened(false);
};
const restartServer = async () => {
try {
setRestartModalOpened(false);
alert({
alertType: 'neutral',
title: t('admin.settings.restarting', 'Restarting Server'),
body: t(
'admin.settings.restartingMessage',
'The server is restarting. Please wait a moment...'
),
});
const response = await fetch('/api/v1/admin/settings/restart', {
method: 'POST',
});
if (response.ok) {
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
} else {
throw new Error('Failed to restart');
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t(
'admin.settings.restartError',
'Failed to restart server. Please restart manually.'
),
});
}
};
return {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
};
}
@@ -1,7 +1,7 @@
/* Toast Container Styles */
.toast-container {
position: fixed;
z-index: 1200;
z-index: 10001; /* Always on top - must match Z_INDEX_TOAST in zIndex.ts */
display: flex;
gap: 12px;
pointer-events: none;
@@ -5,11 +5,11 @@ export interface AppConfig {
baseUrl?: string;
contextPath?: string;
serverPort?: number;
appName?: string;
appNameNavbar?: string;
homeDescription?: string;
languages?: string[];
enableLogin?: boolean;
enableEmailInvites?: boolean;
isAdmin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
+191
View File
@@ -0,0 +1,191 @@
import { useState } from 'react';
import apiClient from '@app/services/apiClient';
import { mergePendingSettings, isFieldPending, hasPendingChanges } from '@app/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 (!Object.prototype.hasOwnProperty.call(current, 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
);
}
+30
View File
@@ -104,4 +104,34 @@ i18n.on('languageChanged', (lng) => {
document.documentElement.lang = lng;
});
/**
* Updates the supported languages list dynamically based on config
* If configLanguages is null/empty, all languages remain available
* Otherwise, only specified languages plus 'en-GB' fallback are enabled
*/
export function updateSupportedLanguages(configLanguages?: string[] | null) {
if (!configLanguages || configLanguages.length === 0) {
// No filter specified - keep all languages
return;
}
// Ensure fallback language is always included
const languagesToSupport = new Set(['en-GB', ...configLanguages]);
// Filter to only valid language codes that exist in our translations
const validLanguages = Array.from(languagesToSupport).filter(
lang => lang in supportedLanguages
);
if (validLanguages.length > 0) {
i18n.options.supportedLngs = validLanguages;
// If current language is not in the new supported list, switch to fallback
const currentLang = i18n.language;
if (currentLang && !validLanguages.includes(currentLang)) {
i18n.changeLanguage('en-GB');
}
}
}
export default i18n;
+3 -2
View File
@@ -150,10 +150,11 @@ export default function HomePage() {
const baseUrl = useBaseUrl();
// Update document meta when tool changes
const appName = config?.appNameNavbar || 'Stirling PDF';
useDocumentMeta({
title: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
title: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
description: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
ogTitle: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
ogDescription: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: selectedToolKey ? `${baseUrl}/og_images/${selectedToolKey}.png` : `${baseUrl}/og_images/home.png`,
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl
@@ -0,0 +1,34 @@
import apiClient from '@app/services/apiClient';
export interface AccountData {
username: string;
role: string;
settings: string; // JSON string of settings
changeCredsFlag: boolean;
oAuth2Login: boolean;
saml2Login: boolean;
}
/**
* Account Service
* Provides functions to interact with account-related backend APIs
*/
export const accountService = {
/**
* Get current user account data
*/
async getAccountData(): Promise<AccountData> {
const response = await apiClient.get<AccountData>('/api/v1/proprietary/ui-data/account');
return response.data;
},
/**
* Change user password
*/
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
const formData = new FormData();
formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password', formData);
},
};
@@ -88,6 +88,10 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate
* Returns true if the error should be suppressed (deduplicated), false otherwise
*/
export async function handleHttpError(error: any): Promise<boolean> {
// Check if this error should skip the global toast (component will handle it)
if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
+107
View File
@@ -0,0 +1,107 @@
import apiClient from '@app/services/apiClient';
export interface Team {
id: number;
name: string;
userCount?: number;
}
export interface TeamMember {
id: number;
username: string;
email?: string;
roleName: string;
enabled: boolean;
team?: {
id: number;
name: string;
};
lastRequest?: Date | null;
}
export interface TeamDetailsResponse {
team: Team;
members: TeamMember[];
availableUsers: TeamMember[];
}
/**
* Team Management Service
* Provides functions to interact with team-related backend APIs
*/
export const teamService = {
/**
* Get all teams with user counts
*/
async getTeams(): Promise<Team[]> {
const response = await apiClient.get<{ teamsWithCounts: Team[] }>('/api/v1/proprietary/ui-data/teams');
return response.data.teamsWithCounts;
},
/**
* Get team details including members
*/
async getTeamDetails(teamId: number): Promise<any> {
const response = await apiClient.get(`/api/v1/proprietary/ui-data/teams/${teamId}`);
return response.data;
},
/**
* Create a new team
*/
async createTeam(name: string): Promise<void> {
const formData = new FormData();
formData.append('name', name);
await apiClient.post('/api/v1/team/create', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Rename an existing team
*/
async renameTeam(teamId: number, newName: string): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
formData.append('newName', newName);
await apiClient.post('/api/v1/team/rename', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Delete a team (only if it has no members)
*/
async deleteTeam(teamId: number): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
await apiClient.post('/api/v1/team/delete', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Add a user to a team
*/
async addUserToTeam(teamId: number, userId: number): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
formData.append('userId', userId.toString());
await apiClient.post('/api/v1/team/addUser', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Move a user to a specific team (used when "removing" from a team - moves to Default)
*/
async moveUserToTeam(username: string, currentRole: string, teamId: number): Promise<void> {
const formData = new FormData();
formData.append('username', username);
formData.append('role', currentRole);
formData.append('teamId', teamId.toString());
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
suppressErrorToast: true,
} as any);
},
};
@@ -0,0 +1,166 @@
import apiClient from '@app/services/apiClient';
export interface User {
id: number;
username: string;
email?: string;
roleName: string; // Translation key like "adminUserSettings.admin"
rolesAsString?: string; // Actual role ID like "ROLE_ADMIN"
enabled: boolean;
isFirstLogin?: boolean;
authenticationType?: string;
team?: {
id: number;
name: string;
};
createdAt?: string;
updatedAt?: string;
// Enriched client-side fields
isActive?: boolean;
lastRequest?: number; // timestamp in milliseconds
}
export interface AdminSettingsData {
users: User[];
userSessions: Record<string, boolean>;
userLastRequest: Record<string, number>; // username -> timestamp in milliseconds
totalUsers: number;
activeUsers: number;
disabledUsers: number;
currentUsername?: string;
roleDetails?: Record<string, string>;
teams?: any[];
maxPaidUsers?: number;
}
export interface CreateUserRequest {
username: string;
password?: string;
role: string;
teamId?: number;
authType: 'password' | 'SSO';
forceChange?: boolean;
}
export interface UpdateUserRoleRequest {
username: string;
role: string;
teamId?: number;
}
export interface InviteUsersRequest {
emails: string; // Comma-separated email addresses
role: string;
teamId?: number;
}
export interface InviteUsersResponse {
successCount: number;
failureCount: number;
message?: string;
errors?: string;
error?: string;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
*/
export const userManagementService = {
/**
* Get all users with session data (admin only)
*/
async getUsers(): Promise<AdminSettingsData> {
const response = await apiClient.get<AdminSettingsData>('/api/v1/proprietary/ui-data/admin-settings');
return response.data;
},
/**
* Get users without a team
*/
async getUsersWithoutTeam(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/v1/users/without-team');
return response.data;
},
/**
* Create a new user (admin only)
*/
async createUser(data: CreateUserRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
if (data.password) {
formData.append('password', data.password);
}
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
formData.append('authType', data.authType);
if (data.forceChange !== undefined) {
formData.append('forceChange', data.forceChange.toString());
}
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
suppressErrorToast: true, // Component will handle error display
} as any);
},
/**
* Update user role and/or team (admin only)
*/
async updateUserRole(data: UpdateUserRoleRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Enable or disable a user (admin only)
*/
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
const formData = new FormData();
formData.append('enabled', enabled.toString());
await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, {
suppressErrorToast: true,
} as any);
},
/**
* Delete a user (admin only)
*/
async deleteUser(username: string): Promise<void> {
await apiClient.post(`/api/v1/user/admin/deleteUser/${username}`, null, {
suppressErrorToast: true,
} as any);
},
/**
* Invite users via email (admin only)
* Sends comma-separated email addresses, creates accounts with random passwords,
* and sends invitation emails
*/
async inviteUsers(data: InviteUsersRequest): Promise<InviteUsersResponse> {
const formData = new FormData();
formData.append('emails', data.emails);
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
const response = await apiClient.post<InviteUsersResponse>(
'/api/v1/user/admin/inviteUsers',
formData,
{
suppressErrorToast: true, // Component will handle error display
} as any
);
return response.data;
},
};
+6
View File
@@ -10,5 +10,11 @@ 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;
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
export const Z_INDEX_TOAST = 10001;
@@ -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 (Object.prototype.hasOwnProperty.call(source, 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
);
}
@@ -15,6 +15,7 @@ export interface User {
role: string;
enabled?: boolean;
is_anonymous?: boolean;
isFirstLogin?: boolean;
app_metadata?: Record<string, any>;
}
+48 -3
View File
@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAppConfig } from '@app/contexts/AppConfigContext'
import HomePage from '@app/pages/HomePage'
import Login from '@app/routes/Login'
import FirstLoginModal from '@app/components/shared/FirstLoginModal'
import { accountService } from '@app/services/accountService'
/**
* Landing component - Smart router based on authentication status
@@ -12,12 +15,45 @@ import Login from '@app/routes/Login'
* If user is not authenticated: Show Login or redirect to /login
*/
export default function Landing() {
const { session, loading: authLoading } = useAuth();
const { session, loading: authLoading, refreshSession } = useAuth();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const [isFirstLogin, setIsFirstLogin] = useState(false);
const [checkingFirstLogin, setCheckingFirstLogin] = useState(false);
const [username, setUsername] = useState('');
const loading = authLoading || configLoading;
// Check if user needs to change password on first login
useEffect(() => {
const checkFirstLogin = async () => {
if (session && config?.enableLogin !== false) {
try {
setCheckingFirstLogin(true)
const accountData = await accountService.getAccountData()
setUsername(accountData.username)
setIsFirstLogin(accountData.changeCredsFlag)
} catch (err) {
console.error('Failed to check first login status:', err)
// If account endpoint fails (404), user probably doesn't have security enabled
setIsFirstLogin(false)
} finally {
setCheckingFirstLogin(false)
}
}
}
checkFirstLogin()
}, [session, config])
const handlePasswordChanged = async () => {
// After password change, backend logs out the user
// Refresh session to detect logout and redirect to login
setIsFirstLogin(false) // Close modal first
await refreshSession()
// The auth system will automatically redirect to login when session is null
}
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
@@ -26,7 +62,7 @@ export default function Landing() {
});
// Show loading while checking auth and config
if (loading) {
if (loading || checkingFirstLogin) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
@@ -47,7 +83,16 @@ export default function Landing() {
// If we have a session, show the main app
if (session) {
return <HomePage />;
return (
<>
<FirstLoginModal
opened={isFirstLogin}
onPasswordChanged={handlePasswordChanged}
username={username}
/>
<HomePage />
</>
);
}
// If we're at home route ("/"), show login directly (marketing/landing page)
+129
View File
@@ -0,0 +1,129 @@
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* RestartHelper - Lightweight utility to restart Stirling-PDF
*
* This helper waits for the old process to exit, then starts the app again
* with the same arguments. It's only active during restart and then exits.
*
* Usage:
* java -jar restart-helper.jar --pid 1234 --app /path/app.jar
* [--java /path/to/java] [--argsFile /path/args.txt]
* [--backoffMs 1000]
*/
public class RestartHelper {
public static void main(String[] args) {
try {
Map<String, String> cli = parseArgs(args);
long pid = Long.parseLong(req(cli, "pid"));
Path appJar = Paths.get(req(cli, "app")).toAbsolutePath().normalize();
String javaBin = cli.getOrDefault("java", "java");
Path argsFile = cli.containsKey("argsFile") ? Paths.get(cli.get("argsFile")) : null;
long backoffMs = Long.parseLong(cli.getOrDefault("backoffMs", "1000"));
if (!Files.isRegularFile(appJar)) {
fail("App jar not found: " + appJar);
}
System.out.println("[restart-helper] Waiting for PID " + pid + " to exit...");
waitForPidToExit(pid);
// Brief pause to allow ports/files to release
if (backoffMs > 0) {
Thread.sleep(backoffMs);
}
List<String> cmd = new ArrayList<>();
cmd.add(javaBin);
cmd.add("-jar");
cmd.add(appJar.toString());
// Load application arguments from file if provided
if (argsFile != null && Files.isRegularFile(argsFile)) {
for (String line : Files.readAllLines(argsFile)) {
if (!line.isBlank()) {
cmd.add(line.trim());
}
}
// Clean up args file after reading
try {
Files.deleteIfExists(argsFile);
} catch (IOException ignored) {
// Best effort cleanup
}
}
System.out.println("[restart-helper] Starting app: " + String.join(" ", cmd));
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.inheritIO(); // Forward logs to same console/service logs
pb.start();
// Exit immediately - leave app running
System.out.println("[restart-helper] App restarted successfully. Helper exiting.");
System.exit(0);
} catch (Exception e) {
System.err.println("[restart-helper] ERROR: " + e.getMessage());
e.printStackTrace();
System.exit(2);
}
}
/**
* Wait for the specified PID to exit
*/
private static void waitForPidToExit(long pid) throws InterruptedException {
try {
// Java 9+: ProcessHandle API
Optional<ProcessHandle> ph = ProcessHandle.of(pid);
while (ph.isPresent() && ph.get().isAlive()) {
Thread.sleep(300);
ph = ProcessHandle.of(pid);
}
} catch (Throwable t) {
// Fallback for older JDKs or if ProcessHandle isn't available
// Just sleep a bit - by the time main exits, socket should be freed
System.out.println("[restart-helper] ProcessHandle not available, using fallback wait");
Thread.sleep(2000);
}
}
/**
* Parse command-line arguments in --key value format
*/
private static Map<String, String> parseArgs(String[] args) {
Map<String, String> map = new HashMap<>();
for (int i = 0; i < args.length; i++) {
if (args[i].startsWith("--")) {
String key = args[i].substring(2);
String val = (i + 1 < args.length && !args[i + 1].startsWith("--"))
? args[++i]
: "true";
map.put(key, val);
}
}
return map;
}
/**
* Get required parameter or fail
*/
private static String req(Map<String, String> map, String key) {
String val = map.get(key);
if (val == null || val.isBlank()) {
fail("Missing required parameter: --" + key);
}
return val;
}
/**
* Print error and exit
*/
private static void fail(String message) {
System.err.println("[restart-helper] ERROR: " + message);
System.exit(2);
}
}