From d0c5d74471b6db261ddddec0f7ce394a92fa5920 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:47:41 +0000 Subject: [PATCH] settingsPage Init selfhost (#4734) # Description of Changes --- ## 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 --- .../common/configuration/AppConfig.java | 14 +- .../common/model/ApplicationProperties.java | 13 +- .../common/service/PostHogService.java | 5 - .../common/service/UserServiceInterface.java | 2 + .../software/common/util/AppArgsCapture.java | 29 + .../software/common/util/JarPathUtil.java | 84 ++ .../model/ApplicationPropertiesLogicTest.java | 8 - .../controller/api/SettingsController.java | 391 +++++++++ .../controller/api/misc/ConfigController.java | 23 +- .../src/main/resources/settings.yml.template | 3 +- .../api/ProprietaryUIDataController.java | 2 +- .../configuration/SecurityConfiguration.java | 7 +- .../api/AdminSettingsController.java | 191 +++- .../controller/api/DatabaseController.java | 126 ++- .../controller/api/TeamController.java | 71 +- .../controller/api/UserController.java | 373 ++++++-- .../security/filter/FirstLoginFilter.java | 77 -- .../proprietary/security/model/User.java | 2 +- .../security/service/EmailService.java | 86 ++ .../security/service/UserService.java | 15 + .../service/ServerCertificateService.java | 35 +- build.gradle | 33 +- docker/backend/Dockerfile | 3 +- docker/backend/Dockerfile.fat | 3 +- docker/backend/Dockerfile.ultra-lite | 3 +- .../public/locales/en-GB/translation.json | 540 ++++++++++++ .../src/core/components/layout/Workbench.tsx | 9 +- .../components/shared/AppConfigLoader.tsx | 24 + .../core/components/shared/AppConfigModal.tsx | 10 +- .../shared/DismissAllErrorsButton.tsx | 3 +- .../components/shared/FirstLoginModal.tsx | 159 ++++ .../src/core/components/shared/Footer.tsx | 37 +- .../components/shared/config/PendingBadge.tsx | 22 + .../config/RestartConfirmationModal.tsx | 68 ++ .../shared/config/configNavSections.tsx | 99 +++ .../configSections/AdminAdvancedSection.tsx | 821 ++++++++++++++++++ .../AdminConnectionsSection.tsx | 409 +++++++++ .../configSections/AdminDatabaseSection.tsx | 272 ++++++ .../configSections/AdminEndpointsSection.tsx | 176 ++++ .../configSections/AdminFeaturesSection.tsx | 209 +++++ .../configSections/AdminGeneralSection.tsx | 526 +++++++++++ .../configSections/AdminLegalSection.tsx | 177 ++++ .../configSections/AdminMailSection.tsx | 195 +++++ .../configSections/AdminPremiumSection.tsx | 136 +++ .../configSections/AdminPrivacySection.tsx | 189 ++++ .../configSections/AdminSecuritySection.tsx | 618 +++++++++++++ .../shared/config/configSections/Overview.tsx | 1 - .../config/configSections/PeopleSection.tsx | 673 ++++++++++++++ .../config/configSections/ProviderCard.tsx | 190 ++++ .../configSections/TeamDetailsSection.tsx | 476 ++++++++++ .../config/configSections/TeamsSection.tsx | 456 ++++++++++ .../configSections/providerDefinitions.ts | 353 ++++++++ .../core/components/shared/config/types.ts | 12 +- .../shared/config/useRestartServer.ts | 60 ++ .../core/components/toast/ToastRenderer.css | 2 +- .../src/core/contexts/AppConfigContext.tsx | 4 +- frontend/src/core/hooks/useAdminSettings.ts | 191 ++++ frontend/src/core/i18n.ts | 30 + frontend/src/core/pages/HomePage.tsx | 5 +- frontend/src/core/services/accountService.ts | 34 + .../src/core/services/httpErrorHandler.ts | 4 + frontend/src/core/services/teamService.ts | 107 +++ .../core/services/userManagementService.ts | 166 ++++ frontend/src/core/styles/zIndex.ts | 6 + .../src/core/utils/settingsPendingHelper.ts | 166 ++++ .../src/proprietary/auth/springAuthClient.ts | 1 + frontend/src/proprietary/routes/Landing.tsx | 51 +- scripts/RestartHelper.java | 129 +++ 68 files changed, 9133 insertions(+), 282 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java create mode 100644 app/common/src/main/java/stirling/software/common/util/JarPathUtil.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java create mode 100644 frontend/src/core/components/shared/AppConfigLoader.tsx create mode 100644 frontend/src/core/components/shared/FirstLoginModal.tsx create mode 100644 frontend/src/core/components/shared/config/PendingBadge.tsx create mode 100644 frontend/src/core/components/shared/config/RestartConfirmationModal.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminAdvancedSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminConnectionsSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminDatabaseSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminEndpointsSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminFeaturesSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminGeneralSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminLegalSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminMailSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminPremiumSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminPrivacySection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/PeopleSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/ProviderCard.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/TeamDetailsSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/TeamsSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/providerDefinitions.ts create mode 100644 frontend/src/core/components/shared/config/useRestartServer.ts create mode 100644 frontend/src/core/hooks/useAdminSettings.ts create mode 100644 frontend/src/core/services/accountService.ts create mode 100644 frontend/src/core/services/teamService.ts create mode 100644 frontend/src/core/services/userManagementService.ts create mode 100644 frontend/src/core/utils/settingsPendingHelper.ts create mode 100644 scripts/RestartHelper.java diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 3bcc48715..272c0b35c 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -74,8 +74,7 @@ public class AppConfig { @Bean(name = "appName") public String appName() { - String homeTitle = applicationProperties.getUi().getAppName(); - return (homeTitle != null) ? homeTitle : "Stirling PDF"; + return "Stirling PDF"; } @Bean(name = "appVersion") @@ -93,9 +92,7 @@ public class AppConfig { @Bean(name = "homeText") public String homeText() { - return (applicationProperties.getUi().getHomeDescription() != null) - ? applicationProperties.getUi().getHomeDescription() - : "null"; + return "null"; } @Bean(name = "languages") @@ -110,11 +107,8 @@ public class AppConfig { @Bean(name = "navBarText") public String navBarText() { - String defaultNavBar = - applicationProperties.getUi().getAppNameNavbar() != null - ? applicationProperties.getUi().getAppNameNavbar() - : applicationProperties.getUi().getAppName(); - return (defaultNavBar != null) ? defaultNavBar : "Stirling PDF"; + String navBar = applicationProperties.getUi().getAppNameNavbar(); + return (navBar != null) ? navBar : "Stirling PDF"; } @Bean(name = "enableAlphaFunctionality") diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index a8861fdd8..5a5ca77b8 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -490,21 +490,9 @@ public class ApplicationProperties { @Data public static class Ui { - private String appName; - private String homeDescription; private String appNameNavbar; private List 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; diff --git a/app/common/src/main/java/stirling/software/common/service/PostHogService.java b/app/common/src/main/java/stirling/software/common/service/PostHogService.java index 0d6353b50..310fc43ab 100644 --- a/app/common/src/main/java/stirling/software/common/service/PostHogService.java +++ b/app/common/src/main/java/stirling/software/common/service/PostHogService.java @@ -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()); diff --git a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java index d4cc25dc0..a833d4c84 100644 --- a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java +++ b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java @@ -6,4 +6,6 @@ public interface UserServiceInterface { String getCurrentUsername(); long getTotalUsersCount(); + + boolean isCurrentUserAdmin(); } diff --git a/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java new file mode 100644 index 000000000..691785187 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java @@ -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> 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); + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java new file mode 100644 index 000000000..738cde8e6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java @@ -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; + } +} diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java index 66078099f..ba98bf0e1 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -112,19 +112,11 @@ class ApplicationPropertiesLogicTest { @Test void ui_getters_return_null_for_blank() { ApplicationProperties.Ui ui = new ApplicationProperties.Ui(); - ui.setAppName(" "); - ui.setHomeDescription(""); ui.setAppNameNavbar(null); - assertNull(ui.getAppName()); - assertNull(ui.getHomeDescription()); assertNull(ui.getAppNameNavbar()); - ui.setAppName("Stirling-PDF"); - ui.setHomeDescription("Home"); ui.setAppNameNavbar("Nav"); - assertEquals("Stirling-PDF", ui.getAppName()); - assertEquals("Home", ui.getHomeDescription()); assertEquals("Nav", ui.getAppNameNavbar()); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java index 48793a98b..9657d8f15 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java @@ -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> getDisabledEndpoints() { return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses()); } + + // ========== GENERAL SETTINGS ========== + + @GetMapping("/admin/settings/general") + @Hidden + public ResponseEntity> getGeneralSettings() { + Map settings = new HashMap<>(); + settings.put("ui", applicationProperties.getUi()); + settings.put( + "system", + Map.of( + "defaultLocale", applicationProperties.getSystem().getDefaultLocale(), + "showUpdate", applicationProperties.getSystem().isShowUpdate(), + "showUpdateOnlyAdmin", + applicationProperties.getSystem().getShowUpdateOnlyAdmin(), + "customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(), + "fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit())); + return ResponseEntity.ok(settings); + } + + @PostMapping("/admin/settings/general") + @Hidden + public ResponseEntity updateGeneralSettings(@RequestBody Map settings) + throws IOException { + // Update UI settings + if (settings.containsKey("ui")) { + Map ui = (Map) 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 system = (Map) 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> getSecuritySettings() { + Map 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 updateSecuritySettings(@RequestBody Map 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 jwt = (Map) 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> getConnectionsSettings() { + Map 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 updateConnectionsSettings( + @RequestBody Map settings) throws IOException { + // OAuth2 settings + if (settings.containsKey("oauth2")) { + Map oauth2 = (Map) settings.get("oauth2"); + if (oauth2.containsKey("enabled")) { + GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled")); + applicationProperties + .getSecurity() + .getOauth2() + .setEnabled((Boolean) oauth2.get("enabled")); + } + 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 saml2 = (Map) settings.get("saml2"); + if (saml2.containsKey("enabled")) { + GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled")); + applicationProperties + .getSecurity() + .getSaml2() + .setEnabled((Boolean) saml2.get("enabled")); + } + 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> getPrivacySettings() { + Map 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 updatePrivacySettings(@RequestBody Map 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> getAdvancedSettings() { + Map 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 updateAdvancedSettings(@RequestBody Map 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."); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 9d94c3ad4..3fc6e3e02 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -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", diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a9055833a..70417f65b 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 5f321a89e..5735027f6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -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 getAuditDashboardData() { AuditDashboardData data = new AuditDashboardData(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index b7e51fe2c..92def884f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 8282cf073..90f23b295 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -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 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 sectionMap = objectMapper.convertValue(sectionData, Map.class); + + if (includePending && !pendingChanges.isEmpty()) { + // Add pending changes block for this section + Map sectionPending = extractPendingForSection(sectionName); + if (!sectionPending.isEmpty()) { + sectionMap.put("_pending", sectionPending); + } + } + + log.debug( + "Admin requested settings section: {} (includePending={})", + sectionName, + includePending); + return ResponseEntity.ok(sectionMap); } catch (IllegalArgumentException e) { log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e); return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -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 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 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 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 extractPendingForSection(String sectionName) { + Map result = new HashMap<>(); + String sectionPrefix = sectionName.toLowerCase() + "."; + + // Find all pending changes for this section + for (Map.Entry entry : pendingChanges.entrySet()) { + String pendingKey = entry.getKey(); + + if (pendingKey.toLowerCase().startsWith(sectionPrefix)) { + // Extract the path within the section (e.g., "security.enableLogin" -> + // "enableLogin") + String pathInSection = pendingKey.substring(sectionPrefix.length()); + Object pendingValue = entry.getValue(); + + // Build nested structure from dot notation + setNestedValue(result, pathInSection, pendingValue); + } + } + + return result; + } + + /** + * Set a value in a nested map using dot notation + * + * @param map The root map + * @param dotPath The dot notation path (e.g., "oauth2.clientSecret") + * @param value The value to set + */ + @SuppressWarnings("unchecked") + private void setNestedValue(Map map, String dotPath, Object value) { + String[] parts = dotPath.split("\\."); + Map current = map; + + // Navigate/create nested maps for all parts except the last + for (int i = 0; i < parts.length - 1; i++) { + String part = parts[i]; + Object nested = current.get(part); + + if (!(nested instanceof Map)) { + nested = new HashMap(); + current.put(part, nested); + } + + current = (Map) nested; + } + + // Set the final value + current.put(parts[parts.length - 1], value); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java index 9a3bcf839..9fe3529ae 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java @@ -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")); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index 84066ec69..e4e9c1e87 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -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 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 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 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 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")); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 6d4b803c2..6920f5bee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -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; @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 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 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 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 { * * 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 updates, Principal principal) + public ResponseEntity updateUserSettings( + @RequestBody Map 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 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 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 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 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 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; + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java deleted file mode 100644 index 3bae72195..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java +++ /dev/null @@ -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 = 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); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 1c342bf5b..02bd08a5b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -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 settings = new HashMap<>(); // Key-value pairs of settings. @CreationTimestamp diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 08860a340..1b9fc19bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -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 = + """ + +
+
+ +
+ Stirling PDF +
+ +
+

Welcome to Stirling PDF!

+

Hi there,

+

You have been invited to join the workspace. Below are your login credentials:

+ +
+

Username: %s

+

Temporary Password: %s

+
+
+

⚠️ Important: You will be required to change your password upon first login for security reasons.

+
+

Please keep these credentials secure and do not share them with anyone.

+

— The Stirling PDF Team

+
+ +
+ © 2025 Stirling PDF. All rights reserved. +
+
+
+ + """ + .formatted(username, temporaryPassword); + + sendPlainEmail(to, subject, body, true); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 80e48dbf6..dc1c8c1bf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -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()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java index a743b21fe..aca98eb75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -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()); diff --git a/build.gradle b/build.gradle index c2980cb5e..9f2dba488 100644 --- a/build.gradle +++ b/build.gradle @@ -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" + } +} diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 58655dfdb..666e18bd3 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -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 diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index bd12e3063..4e63393e8 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -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 diff --git a/docker/backend/Dockerfile.ultra-lite b/docker/backend/Dockerfile.ultra-lite index 0b74e3b0a..0b4b7a939 100644 --- a/docker/backend/Dockerfile.ultra-lite +++ b/docker/backend/Dockerfile.ultra-lite @@ -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 diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 92b41f96c..b72f5cc54 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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": "user@example.com", + "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": "user1@example.com, user2@example.com", + "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." } } diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 91595195d..861e6a365 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -190,7 +190,14 @@ export default function Workbench() { {renderMainContent()} -