Merge branch 'main' into codex/fix-language-settings-override-issue
This commit is contained in:
@@ -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
|
||||
|
||||
+64
-2
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
-1
@@ -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
|
||||
|
||||
+27
-2
@@ -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;
|
||||
|
||||
+77
@@ -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<User> 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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+49
@@ -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
|
||||
? ""
|
||||
: """
|
||||
<div style=\"background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;\">
|
||||
<p style=\"margin: 0;\"><strong>Temporary Password:</strong> %s</p>
|
||||
</div>
|
||||
"""
|
||||
.formatted(newPassword);
|
||||
|
||||
String body =
|
||||
"""
|
||||
<html><body style=\"margin: 0; padding: 0;\">
|
||||
<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">
|
||||
<div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">
|
||||
<div style=\"text-align: center; padding: 20px; background-color: #222;\">
|
||||
<img src=\"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg\" alt=\"Stirling PDF\" style=\"max-height: 60px;\">
|
||||
</div>
|
||||
<div style=\"padding: 30px; color: #333;\">
|
||||
<h2 style=\"color: #222; margin-top: 0;\">Your password was changed</h2>
|
||||
<p>Hello %s,</p>
|
||||
<p>An administrator has updated the password for your Stirling PDF account.</p>
|
||||
%s
|
||||
<p>If you did not expect this change, please contact your administrator immediately.</p>
|
||||
<div style=\"text-align: center; margin: 30px 0;\">
|
||||
<a href=\"%s\" style=\"display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;\">Go to Stirling PDF</a>
|
||||
</div>
|
||||
<p style=\"font-size: 14px; color: #666;\">Or copy and paste this link in your browser:</p>
|
||||
<div style=\"background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;\">
|
||||
%s
|
||||
</div>
|
||||
</div>
|
||||
<div style=\"text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;\">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(username, passwordSection, loginUrl, loginUrl);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
|
||||
@@ -334,6 +334,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const editorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const caretOffsetsRef = useRef<Map<string, number>>(new Map());
|
||||
const composingGroupsRef = useRef<Set<string>>(new Set());
|
||||
const lastSelectedGroupIdRef = useRef<string | null>(null);
|
||||
const widthOverridesRef = useRef<Map<string, number>>(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={{
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={handleClose}
|
||||
size="lg"
|
||||
disabled={processing}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon icon="lock" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t('workspace.people.changePassword.title', 'Change password')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.people.changePassword.subtitle', 'Update the password for')} <strong>{user?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<PasswordInput
|
||||
label={t('workspace.people.changePassword.newPassword', 'New password')}
|
||||
placeholder={t('workspace.people.changePassword.placeholder', 'Enter a new password')}
|
||||
value={form.newPassword}
|
||||
onChange={(event) => setForm({ ...form, newPassword: event.currentTarget.value, generateRandom: false })}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('workspace.people.changePassword.confirmPassword', 'Confirm password')}
|
||||
placeholder={t('workspace.people.changePassword.confirmPlaceholder', 'Re-enter the new password')}
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) => 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}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.generateRandom', 'Generate secure password')}
|
||||
checked={form.generateRandom}
|
||||
disabled={processing || disabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
setForm((prev) => ({ ...prev, generateRandom: checked }));
|
||||
if (event.currentTarget.checked) {
|
||||
handleGeneratePassword();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{passwordPreview && (
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('workspace.people.changePassword.generatedPreview', 'Generated password:')} <strong>{passwordPreview}</strong>
|
||||
</Text>
|
||||
<Tooltip label={t('workspace.people.changePassword.copyTooltip', 'Copy to clipboard')}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={processing}
|
||||
>
|
||||
<LocalIcon icon="content-copy" width="0.9rem" height="0.9rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.sendEmail', 'Email the user about this change')}
|
||||
checked={canEmail && form.sendEmail}
|
||||
onChange={(event) => setForm({ ...form, sendEmail: event.currentTarget.checked })}
|
||||
disabled={!canEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.includePassword', 'Include the new password in the email')}
|
||||
checked={canEmail && form.sendEmail && form.includePassword}
|
||||
onChange={(event) => setForm({ ...form, includePassword: event.currentTarget.checked })}
|
||||
disabled={!canEmail || !form.sendEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.forcePasswordChange', 'Force user to change password on next login')}
|
||||
checked={form.forcePasswordChange}
|
||||
onChange={(event) => setForm({ ...form, forcePasswordChange: event.currentTarget.checked })}
|
||||
disabled={processing || disabled}
|
||||
/>
|
||||
{!canEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{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.')}
|
||||
</Text>
|
||||
)}
|
||||
{canEmail && !form.includePassword && form.sendEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('workspace.people.changePassword.notifyOnly', 'An email will be sent without the password, letting the user know an admin changed it.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button onClick={handleSubmit} loading={processing} fullWidth size="md" disabled={disabled} mt="md">
|
||||
{t('workspace.people.changePassword.submit', 'Update password')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<User | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(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() {
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
|
||||
<Menu.Item onClick={() => openEditModal(user)} disabled={!loginEnabled}>{t('workspace.people.editRole')}</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />}
|
||||
onClick={() => openEditModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.editRole')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
|
||||
onClick={() => handleToggleEnabled(user)}
|
||||
@@ -591,6 +620,14 @@ export default function PeopleSection() {
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
<ChangeUserPasswordModal
|
||||
opened={changePasswordModalOpened}
|
||||
onClose={closeChangePasswordModal}
|
||||
user={passwordUser}
|
||||
onSuccess={fetchData}
|
||||
mailEnabled={mailEnabled}
|
||||
/>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<Modal
|
||||
opened={editUserModalOpened}
|
||||
|
||||
+30
@@ -22,6 +22,7 @@ import { alert } from '@app/components/toast';
|
||||
import { teamService, Team } from '@app/services/teamService';
|
||||
import { User, userManagementService } from '@app/services/userManagementService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
|
||||
|
||||
interface TeamDetailsSectionProps {
|
||||
teamId: number;
|
||||
@@ -38,6 +39,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
const [userLastRequest, setUserLastRequest] = useState<Record<string, number>>({});
|
||||
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
|
||||
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
|
||||
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
|
||||
const [passwordUser, setPasswordUser] = useState<User | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
|
||||
@@ -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')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
{team.name !== 'Internal' && team.name !== 'Default' && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
|
||||
@@ -427,6 +449,14 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<ChangeUserPasswordModal
|
||||
opened={changePasswordModalOpened}
|
||||
onClose={closeChangePasswordModal}
|
||||
user={passwordUser}
|
||||
onSuccess={fetchTeamDetails}
|
||||
mailEnabled={mailEnabled}
|
||||
/>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={addMemberModalOpened}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface AdminSettingsData {
|
||||
grandfatheredUserCount: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
mailEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
@@ -97,6 +98,15 @@ export interface InviteToken {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ChangeUserPasswordRequest {
|
||||
username: string;
|
||||
newPassword?: string;
|
||||
generateRandom?: boolean;
|
||||
sendEmail?: boolean;
|
||||
includePassword?: boolean;
|
||||
forcePasswordChange?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Management Service
|
||||
* Provides functions to interact with user management backend APIs
|
||||
@@ -254,4 +264,31 @@ export const userManagementService = {
|
||||
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Change another user's password (admin only)
|
||||
*/
|
||||
async changeUserPassword(data: ChangeUserPasswordRequest): Promise<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user