Add admin password reset option for users (#5180)

## Summary
- add backend support for admins to reset user passwords and optionally
email notifications when SMTP is enabled
- surface mail capability in admin settings data for the UI
- add a shared change-password modal hooked into People and Team user
actions with random password generation and email options

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934b978fe3c83289b5b95dec79b3d38)

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2025-12-10 10:10:40 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent c980ee10c0
commit 9c03914edd
10 changed files with 577 additions and 11 deletions
@@ -328,6 +328,7 @@ public class ProprietaryUIDataController {
data.setGrandfatheredUserCount(grandfatheredCount);
data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
return ResponseEntity.ok(data);
}
@@ -376,7 +377,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);
@@ -510,6 +511,7 @@ public class ProprietaryUIDataController {
private int grandfatheredUserCount;
private int licenseMaxUsers;
private boolean premiumEnabled;
private boolean mailEnabled;
}
@Data
@@ -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();
}
@@ -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;\">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, passwordSection, loginUrl, loginUrl);
sendPlainEmail(to, subject, body, true);
}
}