feat: password management for tenant portal
All checks were successful
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 47s

- POST /api/tenant/password — change own Logto password
- POST /api/tenant/team/{userId}/password — reset team member password
- Settings page: "Change Password" card with confirm field
- Team page: "Reset Password" button per member with inline form
- LogtoManagementClient.updateUserPassword() via Logto Management API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-11 09:19:48 +02:00
parent dd8553a8b4
commit 4121bd64b2
6 changed files with 205 additions and 7 deletions

View File

@@ -398,6 +398,18 @@ public class LogtoManagementClient {
.toBodilessEntity();
}
/** Update a user's password. */
public void updateUserPassword(String userId, String newPassword) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("password", newPassword))
.retrieve()
.toBodilessEntity();
}
/** Get a user by ID. Returns username, primaryEmail, name. */
@SuppressWarnings("unchecked")
public Map<String, Object> getUser(String userId) {

View File

@@ -4,6 +4,8 @@ import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity;
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
import net.siegeln.cameleer.saas.config.TenantContext;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -39,6 +41,8 @@ public class TenantPortalController {
public record RoleChangeRequest(String roleId) {}
public record PasswordChangeRequest(String password) {}
// --- Endpoints ---
@GetMapping("/dashboard")
@@ -79,6 +83,28 @@ public class TenantPortalController {
return ResponseEntity.ok().build();
}
@PostMapping("/password")
public ResponseEntity<Void> changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
@RequestBody PasswordChangeRequest body) {
try {
portalService.changePassword(jwt.getSubject(), body.password());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/team/{userId}/password")
public ResponseEntity<Void> resetTeamMemberPassword(@PathVariable String userId,
@RequestBody PasswordChangeRequest body) {
try {
portalService.resetTeamMemberPassword(userId, body.password());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/server/restart")
public ResponseEntity<Void> restartServer() {
portalService.restartServer();

View File

@@ -176,6 +176,32 @@ public class TenantPortalService {
logtoClient.assignOrganizationRole(orgId, userId, roleId);
}
public void changePassword(String userId, String newPassword) {
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
logtoClient.updateUserPassword(userId, newPassword);
}
public void resetTeamMemberPassword(String userId, String newPassword) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
// Verify the target user belongs to this tenant's org
var members = logtoClient.listOrganizationMembers(orgId);
boolean isMember = members.stream()
.anyMatch(m -> userId.equals(String.valueOf(m.get("id"))));
if (!isMember) {
throw new IllegalArgumentException("User is not a member of this organization");
}
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
logtoClient.updateUserPassword(userId, newPassword);
}
public TenantSettingsData getSettings() {
TenantEntity tenant = resolveTenant();
String publicEndpoint = provisioningProps.publicProtocol() + "://"