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 f6afa62ea4..35ddddeaac 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 @@ -574,6 +574,16 @@ public class ApplicationProperties { private String username; @ToString.Exclude private String password; private String from; + // STARTTLS upgrades a plain SMTP connection to TLS after connecting (RFC 3207) + private Boolean startTlsEnable = true; + private Boolean startTlsRequired; + // SSL/TLS wrapper for implicit TLS (typically port 465) + private Boolean sslEnable; + // Hostnames or patterns (e.g., "smtp.example.com" or "*") to trust for TLS certificates; + // defaults to "*" (trust all) when not set + private String sslTrust; + // Enables hostname verification for TLS connections + private Boolean sslCheckServerIdentity; } @Data diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java index 4cf0fc8a11..107abbe2b2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java @@ -33,8 +33,12 @@ public class PdfJsonFallbackFontService { public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk"; public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp"; public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean"; + public static final String FALLBACK_FONT_TC_ID = "fallback-noto-tc"; public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic"; public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai"; + public static final String FALLBACK_FONT_DEVANAGARI_ID = "fallback-noto-devanagari"; + public static final String FALLBACK_FONT_MALAYALAM_ID = "fallback-noto-malayalam"; + public static final String FALLBACK_FONT_TIBETAN_ID = "fallback-noto-tibetan"; // Font name aliases map PDF font names to available fallback fonts // This provides better visual consistency when editing PDFs @@ -59,6 +63,22 @@ public class PdfJsonFallbackFontService { Map.entry("dejavuserif", "fallback-dejavu-serif"), Map.entry("dejavumono", "fallback-dejavu-mono"), Map.entry("dejavusansmono", "fallback-dejavu-mono"), + // Traditional Chinese fonts (Taiwan, Hong Kong, Macau) + Map.entry("mingliu", "fallback-noto-tc"), + Map.entry("pmingliu", "fallback-noto-tc"), + Map.entry("microsoftjhenghei", "fallback-noto-tc"), + Map.entry("jhenghei", "fallback-noto-tc"), + Map.entry("kaiti", "fallback-noto-tc"), + Map.entry("kaiu", "fallback-noto-tc"), + Map.entry("dfkaib5", "fallback-noto-tc"), + Map.entry("dfkai", "fallback-noto-tc"), + // Simplified Chinese fonts (Mainland China) - more common + Map.entry("simsun", "fallback-noto-cjk"), + Map.entry("simhei", "fallback-noto-cjk"), + Map.entry("microsoftyahei", "fallback-noto-cjk"), + Map.entry("yahei", "fallback-noto-cjk"), + Map.entry("songti", "fallback-noto-cjk"), + Map.entry("heiti", "fallback-noto-cjk"), // Noto Sans - Google's universal font (use as last resort generic fallback) Map.entry("noto", "fallback-noto-sans"), Map.entry("notosans", "fallback-noto-sans")); @@ -83,6 +103,12 @@ public class PdfJsonFallbackFontService { "classpath:/static/fonts/NotoSansKR-Regular.ttf", "NotoSansKR-Regular", "ttf")), + Map.entry( + FALLBACK_FONT_TC_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansTC-Regular.ttf", + "NotoSansTC-Regular", + "ttf")), Map.entry( FALLBACK_FONT_AR_ID, new FallbackFontSpec( @@ -95,6 +121,24 @@ public class PdfJsonFallbackFontService { "classpath:/static/fonts/NotoSansThai-Regular.ttf", "NotoSansThai-Regular", "ttf")), + Map.entry( + FALLBACK_FONT_DEVANAGARI_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansDevanagari-Regular.ttf", + "NotoSansDevanagari-Regular", + "ttf")), + Map.entry( + FALLBACK_FONT_MALAYALAM_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansMalayalam-Regular.ttf", + "NotoSansMalayalam-Regular", + "ttf")), + Map.entry( + FALLBACK_FONT_TIBETAN_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSerifTibetan-Regular.ttf", + "NotoSerifTibetan-Regular", + "ttf")), // Liberation Sans family Map.entry( "fallback-liberation-sans", @@ -484,6 +528,20 @@ public class PdfJsonFallbackFontService { */ public String resolveFallbackFontId(int codePoint) { Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint); + + // Bopomofo is primarily used in Taiwan for Traditional Chinese phonetic annotation + if (block == Character.UnicodeBlock.BOPOMOFO + || block == Character.UnicodeBlock.BOPOMOFO_EXTENDED) { + return FALLBACK_FONT_TC_ID; + } + + // Compatibility ideographs are primarily used by Traditional Chinese encodings (e.g., Big5, + // HKSCS) so prefer the Traditional Chinese fallback here. + if (block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS + || block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT) { + return FALLBACK_FONT_TC_ID; + } + if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B @@ -492,19 +550,23 @@ public class PdfJsonFallbackFontService { || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F || block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION - || block == Character.UnicodeBlock.BOPOMOFO - || block == Character.UnicodeBlock.BOPOMOFO_EXTENDED || block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) { return FALLBACK_FONT_CJK_ID; } Character.UnicodeScript script = Character.UnicodeScript.of(codePoint); return switch (script) { + // HAN script is used by both Simplified and Traditional Chinese + // Default to Simplified (mainland China, 1.4B speakers) as it's more common + // Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU, etc.) case HAN -> FALLBACK_FONT_CJK_ID; case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID; case HANGUL -> FALLBACK_FONT_KR_ID; case ARABIC -> FALLBACK_FONT_AR_ID; case THAI -> FALLBACK_FONT_TH_ID; + case DEVANAGARI -> FALLBACK_FONT_DEVANAGARI_ID; + case MALAYALAM -> FALLBACK_FONT_MALAYALAM_ID; + case TIBETAN -> FALLBACK_FONT_TIBETAN_ID; default -> FALLBACK_FONT_ID; }; } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 4c2ec003e3..5a50ef9035 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -104,6 +104,11 @@ mail: username: '' # SMTP server username password: '' # SMTP server password from: '' # sender email address + startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server + startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported + sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465) + sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty + sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS legal: termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder diff --git a/app/core/src/main/resources/static/fonts/NotoSansDevanagari-Regular.ttf b/app/core/src/main/resources/static/fonts/NotoSansDevanagari-Regular.ttf new file mode 100644 index 0000000000..9a8c4b105e Binary files /dev/null and b/app/core/src/main/resources/static/fonts/NotoSansDevanagari-Regular.ttf differ diff --git a/app/core/src/main/resources/static/fonts/NotoSansMalayalam-Regular.ttf b/app/core/src/main/resources/static/fonts/NotoSansMalayalam-Regular.ttf new file mode 100644 index 0000000000..a5a0fe65ab Binary files /dev/null and b/app/core/src/main/resources/static/fonts/NotoSansMalayalam-Regular.ttf differ diff --git a/app/core/src/main/resources/static/fonts/NotoSansTC-Regular.ttf b/app/core/src/main/resources/static/fonts/NotoSansTC-Regular.ttf new file mode 100644 index 0000000000..75e2b0806a Binary files /dev/null and b/app/core/src/main/resources/static/fonts/NotoSansTC-Regular.ttf differ diff --git a/app/core/src/main/resources/static/fonts/NotoSerifTibetan-Regular.ttf b/app/core/src/main/resources/static/fonts/NotoSerifTibetan-Regular.ttf new file mode 100644 index 0000000000..06b817d5ca Binary files /dev/null and b/app/core/src/main/resources/static/fonts/NotoSerifTibetan-Regular.ttf differ 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 4c7f03c7e9..88fddb7eaf 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 @@ -332,6 +332,7 @@ public class ProprietaryUIDataController { data.setGrandfatheredUserCount(grandfatheredCount); data.setLicenseMaxUsers(licenseMaxUsers); data.setPremiumEnabled(premiumEnabled); + data.setMailEnabled(applicationProperties.getMail().isEnabled()); return ResponseEntity.ok(data); } @@ -380,7 +381,7 @@ public class ProprietaryUIDataController { data.setUsername(username); data.setRole(user.get().getRolesAsString()); data.setSettings(settingsJson); - data.setChangeCredsFlag(user.get().isFirstLogin()); + data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange()); data.setOAuth2Login(isOAuth2Login); data.setSaml2Login(isSaml2Login); @@ -516,6 +517,7 @@ public class ProprietaryUIDataController { private int grandfatheredUserCount; private int licenseMaxUsers; private boolean premiumEnabled; + private boolean mailEnabled; } @Data diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java index 6a565cade3..6538b7ee97 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java @@ -33,7 +33,8 @@ public class MailConfig { // Creates a new instance of JavaMailSenderImpl, which is a Spring implementation JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); - mailSender.setHost(mailProperties.getHost()); + String host = mailProperties.getHost(); + mailSender.setHost(host); mailSender.setPort(mailProperties.getPort()); mailSender.setDefaultEncoding("UTF-8"); @@ -70,8 +71,32 @@ public class MailConfig { log.info("SMTP authentication disabled - no credentials provided"); } + boolean startTlsEnabled = + mailProperties.getStartTlsEnable() == null || mailProperties.getStartTlsEnable(); // Enables STARTTLS to encrypt the connection if supported by the SMTP server - props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.smtp.starttls.enable", Boolean.toString(startTlsEnabled)); + if (mailProperties.getStartTlsRequired() != null) { + props.put( + "mail.smtp.starttls.required", mailProperties.getStartTlsRequired().toString()); + } + + if (mailProperties.getSslEnable() != null) { + props.put("mail.smtp.ssl.enable", mailProperties.getSslEnable().toString()); + } + + // Trust the configured host to allow STARTTLS with self-signed certificates + String sslTrust = mailProperties.getSslTrust(); + if (sslTrust == null || sslTrust.trim().isEmpty()) { + sslTrust = "*"; + } + if (sslTrust != null && !sslTrust.trim().isEmpty()) { + props.put("mail.smtp.ssl.trust", sslTrust); + } + if (mailProperties.getSslCheckServerIdentity() != null) { + props.put( + "mail.smtp.ssl.checkserveridentity", + mailProperties.getSslCheckServerIdentity().toString()); + } // Returns the configured mail sender, ready to send emails return mailSender; 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 f2b3fd5101..6a18aeff06 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 @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.web.bind.annotation.*; +import jakarta.mail.MessagingException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.transaction.Transactional; @@ -236,6 +238,8 @@ public class UserController { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); } + // Set flags before changing password so they're saved together + user.setForcePasswordChange(false); userService.changePassword(user, newPassword); userService.changeFirstUse(user, false); // Logout using Spring's utility @@ -584,6 +588,79 @@ public class UserController { return ResponseEntity.ok(Map.of("message", "User role updated successfully")); } + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/admin/changePasswordForUser") + public ResponseEntity changePasswordForUser( + @RequestParam(name = "username") String username, + @RequestParam(name = "newPassword", required = false) String newPassword, + @RequestParam(name = "generateRandom", defaultValue = "false") boolean generateRandom, + @RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail, + @RequestParam(name = "includePassword", defaultValue = "false") boolean includePassword, + @RequestParam(name = "forcePasswordChange", defaultValue = "false") + boolean forcePasswordChange, + HttpServletRequest request, + Authentication authentication) + throws SQLException, UnsupportedProviderException, MessagingException { + Optional userOpt = userService.findByUsernameIgnoreCase(username); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); + } + + String currentUsername = authentication.getName(); + if (currentUsername.equalsIgnoreCase(username)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot change your own password.")); + } + + User user = userOpt.get(); + + String finalPassword = newPassword; + if (generateRandom) { + finalPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + if (finalPassword == null || finalPassword.trim().isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "New password is required.")); + } + + // Set force password change flag before changing password so both are saved together + user.setForcePasswordChange(forcePasswordChange); + userService.changePassword(user, finalPassword); + + // Invalidate all active sessions to force reauthentication + userService.invalidateUserSessions(username); + + if (sendEmail) { + if (emailService.isEmpty() || !applicationProperties.getMail().isEnabled()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email is not configured.")); + } + + String userEmail = user.getUsername(); + // Check if username is a valid email format + if (userEmail == null || userEmail.isBlank() || !userEmail.contains("@")) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "User's email is not a valid email address. Notifications are disabled.")); + } + + String loginUrl = buildLoginUrl(request); + emailService + .get() + .sendPasswordChangedNotification( + userEmail, + user.getUsername(), + includePassword ? finalPassword : null, + loginUrl); + } + + return ResponseEntity.ok(Map.of("message", "User password updated successfully")); + } + @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/changeUserEnabled/{username}") public ResponseEntity changeUserEnabled( 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 8207eae28b..c53893a8b4 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 @@ -59,6 +59,9 @@ public class User implements UserDetails, Serializable { @Column(name = "hasCompletedInitialSetup") private Boolean hasCompletedInitialSetup = false; + @Column(name = "forcePasswordChange") + private Boolean forcePasswordChange = false; + @Column(name = "roleName") private String roleName; @@ -117,6 +120,14 @@ public class User implements UserDetails, Serializable { this.hasCompletedInitialSetup = hasCompletedInitialSetup; } + public boolean isForcePasswordChange() { + return forcePasswordChange != null && forcePasswordChange; + } + + public void setForcePasswordChange(boolean forcePasswordChange) { + this.forcePasswordChange = forcePasswordChange; + } + public void setAuthenticationType(AuthenticationType authenticationType) { this.authenticationType = authenticationType.toString().toLowerCase(); } 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 8df76fca3c..d4ecf81618 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 @@ -223,4 +223,53 @@ public class EmailService { sendPlainEmail(to, subject, body, true); } + + @Async + public void sendPasswordChangedNotification( + String to, String username, String newPassword, String loginUrl) throws MessagingException { + String subject = "Your Stirling PDF password has been updated"; + + String passwordSection = + newPassword == null + ? "" + : """ +
+

Temporary Password: %s

+
+ """ + .formatted(newPassword); + + String body = + """ + +
+
+
+ \"Stirling +
+
+

Your password was changed

+

Hello %s,

+

An administrator has updated the password for your Stirling PDF account.

+ %s +

If you did not expect this change, please contact your administrator immediately.

+
+ Go to Stirling PDF +
+

Or copy and paste this link in your browser:

+
+ %s +
+
+
+ © 2025 Stirling PDF. All rights reserved. +
+
+
+ + """ + .formatted(username, passwordSection, loginUrl, loginUrl); + + sendPlainEmail(to, subject, body, true); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java index 3db3493f4b..5df56f8eff 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java @@ -27,6 +27,11 @@ class MailConfigTest { when(mailProps.getPort()).thenReturn(587); when(mailProps.getUsername()).thenReturn("user@example.com"); when(mailProps.getPassword()).thenReturn("password"); + when(mailProps.getStartTlsEnable()).thenReturn(null); + when(mailProps.getStartTlsRequired()).thenReturn(null); + when(mailProps.getSslEnable()).thenReturn(null); + when(mailProps.getSslTrust()).thenReturn(null); + when(mailProps.getSslCheckServerIdentity()).thenReturn(null); } @Test @@ -50,6 +55,32 @@ class MailConfigTest { () -> assertEquals("password", impl.getPassword()), () -> assertEquals("UTF-8", impl.getDefaultEncoding()), () -> assertEquals("true", props.getProperty("mail.smtp.auth")), - () -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable"))); + () -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")), + () -> assertEquals(null, props.getProperty("mail.smtp.starttls.required")), + () -> assertEquals(null, props.getProperty("mail.smtp.ssl.enable")), + () -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust"))); + } + + @Test + void shouldRespectExplicitTlsOverrides() { + ApplicationProperties appProps = mock(ApplicationProperties.class); + when(mailProps.getStartTlsEnable()).thenReturn(false); + when(mailProps.getStartTlsRequired()).thenReturn(true); + when(mailProps.getSslEnable()).thenReturn(true); + when(mailProps.getSslTrust()).thenReturn("*"); + when(mailProps.getSslCheckServerIdentity()).thenReturn(true); + when(appProps.getMail()).thenReturn(mailProps); + + MailConfig config = new MailConfig(appProps); + JavaMailSenderImpl impl = (JavaMailSenderImpl) config.javaMailSender(); + + Properties props = impl.getJavaMailProperties(); + + assertAll( + () -> assertEquals("false", props.getProperty("mail.smtp.starttls.enable")), + () -> assertEquals("true", props.getProperty("mail.smtp.starttls.required")), + () -> assertEquals("true", props.getProperty("mail.smtp.ssl.enable")), + () -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust")), + () -> assertEquals("true", props.getProperty("mail.smtp.ssl.checkserveridentity"))); } } diff --git a/build.gradle b/build.gradle index 85f6a480de..64908ee4ac 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.1.1' + version = '2.1.2' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index fcdcf592f2..9d9278922e 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5368,6 +5368,31 @@ error = "Failed to update user status" success = "User deleted successfully" error = "Failed to delete user" +[workspace.people.changePassword] +action = "Change password" +title = "Change password" +subtitle = "Update the password for" +newPassword = "New password" +confirmPassword = "Confirm password" +placeholder = "Enter a new password" +confirmPlaceholder = "Re-enter the new password" +passwordRequired = "Please enter a new password" +passwordMismatch = "Passwords do not match" +generateRandom = "Generate secure password" +generatedPreview = "Generated password:" +copyTooltip = "Copy to clipboard" +copiedToClipboard = "Password copied to clipboard" +copyFailed = "Failed to copy password" +sendEmail = "Email the user about this change" +includePassword = "Include the new password in the email" +forcePasswordChange = "Force user to change password on next login" +emailUnavailable = "This user's email is not a valid email address. Notifications are disabled." +smtpDisabled = "Email notifications require SMTP to be enabled in settings." +notifyOnly = "An email will be sent without the password, letting the user know an admin changed it." +submit = "Update password" +success = "Password updated successfully" +error = "Failed to update password" + [workspace.people.emailInvite] tab = "Email Invite" description = "Type or paste in emails below, separated by commas. Users will receive login credentials via email." diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 45d1ef4546..514b98e1c7 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -195,7 +195,7 @@ export function useOnboardingOrchestrator( accountService.getAccountData(), accountService.getLoginPageData(), ]); - + setRuntimeState((prev) => ({ ...prev, requiresPasswordChange: accountData.changeCredsFlag, @@ -226,8 +226,12 @@ export function useOnboardingOrchestrator( }), [serverExperience, runtimeState]); const activeFlow = useMemo(() => { + // If password change is required, ONLY show the first-login step + if (runtimeState.requiresPasswordChange) { + return ONBOARDING_STEPS.filter((step) => step.id === 'first-login'); + } return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext)); - }, [conditionContext]); + }, [conditionContext, runtimeState.requiresPasswordChange]); // Wait for config AND admin status before calculating initial step const adminStatusResolved = !configLoading && ( @@ -238,23 +242,31 @@ export function useOnboardingOrchestrator( useEffect(() => { if (configLoading || !adminStatusResolved || activeFlow.length === 0) return; - + let firstUnseenIndex = -1; for (let i = 0; i < activeFlow.length; i++) { - if (!hasSeenStep(activeFlow[i].id)) { + // Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true + const isFirstLoginStep = activeFlow[i].id === 'first-login'; + const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id); + + if (shouldTreatAsUnseen) { firstUnseenIndex = i; break; } } - - if (firstUnseenIndex === -1) { + + // Force reset index when password change is required (overrides initialIndexSet) + if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) { + setCurrentStepIndex(0); + initialIndexSet.current = true; + } else if (firstUnseenIndex === -1) { setCurrentStepIndex(activeFlow.length); initialIndexSet.current = true; } else if (!initialIndexSet.current) { setCurrentStepIndex(firstUnseenIndex); initialIndexSet.current = true; } - }, [activeFlow, configLoading, adminStatusResolved]); + }, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]); const totalSteps = activeFlow.length; @@ -303,10 +315,13 @@ export function useOnboardingOrchestrator( if (!currentStep || isLoading) { return; } - if (hasSeenStep(currentStep.id)) { + // Special case: never auto-complete first-login step if requiresPasswordChange is true + const isFirstLoginStep = currentStep.id === 'first-login'; + + if (!isFirstLoginStep && hasSeenStep(currentStep.id)) { complete(); } - }, [currentStep, isLoading, complete]); + }, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]); const updateRuntimeState = useCallback((updates: Partial) => { persistRuntimeState(updates); diff --git a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx index 4eeff845b3..3c5b45e0e6 100644 --- a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx +++ b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -334,6 +334,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { const containerRef = useRef(null); const editorRefs = useRef>(new Map()); const caretOffsetsRef = useRef>(new Map()); + const composingGroupsRef = useRef>(new Set()); const lastSelectedGroupIdRef = useRef(null); const widthOverridesRef = useRef>(widthOverrides); const resizingRef = useRef<{ @@ -614,6 +615,18 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { [editingGroupId, onGroupEdit], ); + const handleCompositionStart = useCallback((groupId: string) => { + composingGroupsRef.current.add(groupId); + }, []); + + const handleCompositionEnd = useCallback( + (element: HTMLElement, pageIndex: number, groupId: string) => { + composingGroupsRef.current.delete(groupId); + syncEditorValue(element, pageIndex, groupId); + }, + [syncEditorValue], + ); + const handleMergeSelection = useCallback(() => { if (!canMergeSelection) { return; @@ -2279,6 +2292,10 @@ const selectionToolbarPosition = useMemo(() => { contentEditable suppressContentEditableWarning data-editor-group={group.id} + onCompositionStart={() => handleCompositionStart(group.id)} + onCompositionEnd={(event) => + handleCompositionEnd(event.currentTarget, group.pageIndex, group.id) + } onFocus={(event) => { const primaryFont = fontFamily.split(',')[0]?.replace(/['"]/g, '').trim(); if (primaryFont && typeof document !== 'undefined') { @@ -2300,6 +2317,7 @@ const selectionToolbarPosition = useMemo(() => { event.stopPropagation(); }} onBlur={(event) => { + composingGroupsRef.current.delete(group.id); syncEditorValue(event.currentTarget, group.pageIndex, group.id, { skipCaretRestore: true, }); @@ -2309,6 +2327,9 @@ const selectionToolbarPosition = useMemo(() => { setEditingGroupId(null); }} onInput={(event) => { + if (composingGroupsRef.current.has(group.id)) { + return; + } syncEditorValue(event.currentTarget, group.pageIndex, group.id); }} style={{ diff --git a/frontend/src/proprietary/components/shared/ChangeUserPasswordModal.tsx b/frontend/src/proprietary/components/shared/ChangeUserPasswordModal.tsx new file mode 100644 index 0000000000..9e0cd222c4 --- /dev/null +++ b/frontend/src/proprietary/components/shared/ChangeUserPasswordModal.tsx @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActionIcon, + Button, + Checkbox, + CloseButton, + Group, + Modal, + PasswordInput, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { alert } from '@app/components/toast'; +import { ChangeUserPasswordRequest, User, userManagementService } from '@app/services/userManagementService'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; + +interface ChangeUserPasswordModalProps { + opened: boolean; + onClose: () => void; + user: User | null; + onSuccess: () => void; + mailEnabled: boolean; +} + +function generateSecurePassword() { + const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&'; + const length = 14; + let password = ''; + const charsetLength = charset.length; + const uint8Array = new Uint8Array(length); + window.crypto.getRandomValues(uint8Array); + // To avoid modulo bias, discard values >= 256 - (256 % charsetLength) + for (let i = 0; password.length < length; ) { + const randomByte = uint8Array[i]; + i++; + if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) { + // Discard and generate a new random value + if (i >= uint8Array.length) { + // Exhausted the array, fill a new one + window.crypto.getRandomValues(uint8Array); + i = 0; + } + continue; + } + const randomIndex = randomByte % charsetLength; + password += charset[randomIndex]; + } + return password; +} + +export default function ChangeUserPasswordModal({ opened, onClose, user, onSuccess, mailEnabled }: ChangeUserPasswordModalProps) { + const { t } = useTranslation(); + const [form, setForm] = useState({ + newPassword: '', + confirmPassword: '', + generateRandom: false, + sendEmail: false, + includePassword: false, + forcePasswordChange: false, + }); + const [processing, setProcessing] = useState(false); + + const disabled = !user; + + const handleGeneratePassword = () => { + const generated = generateSecurePassword(); + setForm((prev) => ({ ...prev, newPassword: generated, confirmPassword: generated, generateRandom: true })); + }; + + const handleCopyPassword = async () => { + if (!form.newPassword) return; + try { + await navigator.clipboard.writeText(form.newPassword); + alert({ alertType: 'success', title: t('workspace.people.changePassword.copiedToClipboard', 'Password copied to clipboard') }); + } catch (_error) { + alert({ alertType: 'error', title: t('workspace.people.changePassword.copyFailed', 'Failed to copy password') }); + } + }; + + const resetState = () => { + setForm({ + newPassword: '', + confirmPassword: '', + generateRandom: false, + sendEmail: false, + includePassword: false, + forcePasswordChange: false, + }); + }; + + const handleClose = () => { + if (processing) return; + resetState(); + onClose(); + }; + + const handleSubmit = async () => { + if (!user) return; + + if (!form.generateRandom && !form.newPassword.trim()) { + alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordRequired', 'Please enter a new password') }); + return; + } + + if (!form.generateRandom && form.newPassword !== form.confirmPassword) { + alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') }); + return; + } + + const payload: ChangeUserPasswordRequest = { + username: user.username, + newPassword: form.newPassword, // Always send the password (frontend generates it when generateRandom is true) + generateRandom: false, // Not needed since we're generating on frontend + sendEmail: form.sendEmail, + includePassword: form.includePassword, + forcePasswordChange: form.forcePasswordChange, + }; + + try { + setProcessing(true); + await userManagementService.changeUserPassword(payload); + alert({ alertType: 'success', title: t('workspace.people.changePassword.success', 'Password updated successfully') }); + onSuccess(); + handleClose(); + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.changePassword.error', 'Failed to update password'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + + useEffect(() => { + if (opened) { + setForm({ + newPassword: '', + confirmPassword: '', + generateRandom: false, + sendEmail: false, + includePassword: false, + forcePasswordChange: false, + }); + } + }, [opened, user?.username]); + + // Check if username is a valid email format + const isValidEmail = (email: string | undefined) => { + if (!email) return false; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const canEmail = mailEnabled && isValidEmail(user?.username); + const passwordPreview = useMemo(() => form.newPassword && form.generateRandom ? form.newPassword : '', [form.generateRandom, form.newPassword]); + + return ( + +
+ + + + + + {t('workspace.people.changePassword.title', 'Change password')} + + + {t('workspace.people.changePassword.subtitle', 'Update the password for')} {user?.username} + + + + + setForm({ ...form, newPassword: event.currentTarget.value, generateRandom: false })} + disabled={processing || disabled || form.generateRandom} + data-autofocus + /> + setForm({ ...form, confirmPassword: event.currentTarget.value, generateRandom: false })} + disabled={processing || disabled || form.generateRandom} + error={!form.generateRandom && form.confirmPassword && form.newPassword !== form.confirmPassword ? t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') : undefined} + /> + + { + const checked = event.currentTarget.checked; + setForm((prev) => ({ ...prev, generateRandom: checked })); + if (event.currentTarget.checked) { + handleGeneratePassword(); + } + }} + /> + {passwordPreview && ( + + + {t('workspace.people.changePassword.generatedPreview', 'Generated password:')} {passwordPreview} + + + + + + + + )} + + + + + setForm({ ...form, sendEmail: event.currentTarget.checked })} + disabled={!canEmail || processing} + /> + setForm({ ...form, includePassword: event.currentTarget.checked })} + disabled={!canEmail || !form.sendEmail || processing} + /> + setForm({ ...form, forcePasswordChange: event.currentTarget.checked })} + disabled={processing || disabled} + /> + {!canEmail && ( + + {mailEnabled + ? t('workspace.people.changePassword.emailUnavailable', "This user's email is not a valid email address. Notifications are disabled.") + : t('workspace.people.changePassword.smtpDisabled', 'Email notifications require SMTP to be enabled in settings.')} + + )} + {canEmail && !form.includePassword && form.sendEmail && ( + + {t('workspace.people.changePassword.notifyOnly', 'An email will be sent without the password, letting the user know an admin changed it.')} + + )} + + + + +
+
+ ); +} diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 9c4f56ba07..bc2f69c3a0 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -30,6 +30,7 @@ import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBann import { useNavigate } from 'react-router-dom'; import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton'; import { useLicense } from '@app/contexts/LicenseContext'; +import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal'; export default function PeopleSection() { const { t } = useTranslation(); @@ -43,8 +44,11 @@ export default function PeopleSection() { const [searchQuery, setSearchQuery] = useState(''); const [inviteModalOpened, setInviteModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false); + const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false); + const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); const [processing, setProcessing] = useState(false); + const [mailEnabled, setMailEnabled] = useState(false); // License information const [licenseInfo, setLicenseInfo] = useState<{ @@ -119,6 +123,7 @@ export default function PeopleSection() { premiumEnabled: adminData.premiumEnabled, totalUsers: adminData.totalUsers, }); + setMailEnabled(adminData.mailEnabled); } else { // Provide example data when login is disabled const exampleUsers: User[] = [ @@ -179,6 +184,7 @@ export default function PeopleSection() { setUsers(exampleUsers); setTeams(exampleTeams); + setMailEnabled(false); // Example license information setLicenseInfo({ @@ -267,6 +273,16 @@ export default function PeopleSection() { setEditUserModalOpened(true); }; + const openChangePasswordModal = (user: User) => { + setPasswordUser(user); + setChangePasswordModalOpened(true); + }; + + const closeChangePasswordModal = () => { + setChangePasswordModalOpened(false); + setPasswordUser(null); + }; + const closeEditModal = () => { setEditUserModalOpened(false); setSelectedUser(null); @@ -562,7 +578,20 @@ export default function PeopleSection() { - openEditModal(user)} disabled={!loginEnabled}>{t('workspace.people.editRole')} + } + onClick={() => openEditModal(user)} + disabled={!loginEnabled} + > + {t('workspace.people.editRole')} + + } + onClick={() => openChangePasswordModal(user)} + disabled={!loginEnabled} + > + {t('workspace.people.changePassword.action', 'Change password')} + : } onClick={() => handleToggleEnabled(user)} @@ -591,6 +620,14 @@ export default function PeopleSection() { onSuccess={fetchData} /> + + {/* Edit User Modal */} >({}); const [addMemberModalOpened, setAddMemberModalOpened] = useState(false); const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false); + const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false); + const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); const [selectedUserId, setSelectedUserId] = useState(''); const [selectedTeamId, setSelectedTeamId] = useState(''); @@ -47,6 +50,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio const [licenseInfo, setLicenseInfo] = useState<{ availableSlots: number; } | null>(null); + const [mailEnabled, setMailEnabled] = useState(false); useEffect(() => { fetchTeamDetails(); @@ -70,6 +74,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio setLicenseInfo({ availableSlots: adminData.availableSlots, }); + setMailEnabled(adminData.mailEnabled); } catch (error) { console.error('Failed to fetch team details:', error); alert({ alertType: 'error', title: t('workspace.teams.loadError', 'Failed to load team details') }); @@ -172,6 +177,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio setChangeTeamModalOpened(true); }; + const openChangePasswordModal = (user: User) => { + setPasswordUser(user); + setChangePasswordModalOpened(true); + }; + + const closeChangePasswordModal = () => { + setChangePasswordModalOpened(false); + setPasswordUser(null); + }; + const handleChangeTeam = async () => { if (!selectedUser || !selectedTeamId) { alert({ alertType: 'error', title: t('workspace.teams.changeTeam.selectTeamRequired', 'Please select a team') }); @@ -398,6 +413,13 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio > {t('workspace.teams.changeTeam.label', 'Change Team')} + } + onClick={() => openChangePasswordModal(user)} + disabled={processing} + > + {t('workspace.people.changePassword.action', 'Change password')} + {team.name !== 'Internal' && team.name !== 'Default' && ( } @@ -427,6 +449,14 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio + + {/* Add Member Modal */} ('/api/v1/invite/cleanup'); return response.data; }, + + /** + * Change another user's password (admin only) + */ + async changeUserPassword(data: ChangeUserPasswordRequest): Promise { + const formData = new FormData(); + formData.append('username', data.username); + if (data.newPassword) { + formData.append('newPassword', data.newPassword); + } + if (data.generateRandom !== undefined) { + formData.append('generateRandom', data.generateRandom.toString()); + } + if (data.sendEmail !== undefined) { + formData.append('sendEmail', data.sendEmail.toString()); + } + if (data.includePassword !== undefined) { + formData.append('includePassword', data.includePassword.toString()); + } + if (data.forcePasswordChange !== undefined) { + formData.append('forcePasswordChange', data.forcePasswordChange.toString()); + } + + await apiClient.post('/api/v1/user/admin/changePasswordForUser', formData, { + suppressErrorToast: true, // Component will handle error display + } as any); + }, };