From 022b6d97228e1795c0f0f46fb6cbe364536c3b3b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:46:42 +0200 Subject: [PATCH] feat: add vendor admin management (list, create/invite, remove, reset password/MFA) Co-Authored-By: Claude Sonnet 4.6 --- .../saas/vendor/VendorAdminController.java | 53 ++++++++ .../saas/vendor/VendorAdminService.java | 119 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java new file mode 100644 index 0000000..f1b82d9 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java @@ -0,0 +1,53 @@ +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.vendor.VendorAdminService.CreateAdminRequest; +import net.siegeln.cameleer.saas.vendor.VendorAdminService.CreateAdminResponse; +import net.siegeln.cameleer.saas.vendor.VendorAdminService.VendorAdmin; +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.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vendor/admins") +public class VendorAdminController { + + private final VendorAdminService vendorAdminService; + + public VendorAdminController(VendorAdminService vendorAdminService) { + this.vendorAdminService = vendorAdminService; + } + + @GetMapping + public List listAdmins() { + return vendorAdminService.listAdmins(); + } + + @PostMapping + public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) { + return vendorAdminService.createAdmin(request); + } + + @DeleteMapping("/{userId}") + public ResponseEntity removeAdmin(@AuthenticationPrincipal Jwt jwt, + @PathVariable String userId) { + vendorAdminService.removeAdmin(userId, jwt.getSubject()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{userId}/reset-password") + public ResponseEntity resetPassword(@PathVariable String userId, + @RequestBody Map body) { + vendorAdminService.resetAdminPassword(userId, body.get("password")); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{userId}/mfa") + public ResponseEntity resetMfa(@PathVariable String userId) { + vendorAdminService.resetAdminMfa(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java new file mode 100644 index 0000000..7969390 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java @@ -0,0 +1,119 @@ +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.account.AccountService; +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +@Service +public class VendorAdminService { + + private static final Logger log = LoggerFactory.getLogger(VendorAdminService.class); + private static final String VENDOR_ROLE_NAME = "saas-vendor"; + + private final LogtoManagementClient logtoClient; + private final AccountService accountService; + private final EmailConnectorService emailConnectorService; + + public VendorAdminService(LogtoManagementClient logtoClient, + AccountService accountService, + EmailConnectorService emailConnectorService) { + this.logtoClient = logtoClient; + this.accountService = accountService; + this.emailConnectorService = emailConnectorService; + } + + // --- Records --- + + public record VendorAdmin(String userId, String name, String email) {} + public record CreateAdminRequest(String email, String tempPassword) {} + public record CreateAdminResponse(boolean invited, String tempPassword) {} + + // --- Methods --- + + private String getVendorRoleId() { + var role = logtoClient.getRoleByName(VENDOR_ROLE_NAME); + if (role == null) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Vendor role '" + VENDOR_ROLE_NAME + "' not found in Logto"); + } + return String.valueOf(role.get("id")); + } + + public List listAdmins() { + String roleId = getVendorRoleId(); + var users = logtoClient.listRoleUsers(roleId); + return users.stream() + .map(u -> new VendorAdmin( + String.valueOf(u.get("id")), + u.get("name") != null ? String.valueOf(u.get("name")) : "", + u.get("primaryEmail") != null ? String.valueOf(u.get("primaryEmail")) : "" + )) + .toList(); + } + + public CreateAdminResponse createAdmin(CreateAdminRequest request) { + if (request.email() == null || request.email().isBlank() || !request.email().contains("@")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Valid email address required"); + } + + String roleId = getVendorRoleId(); + boolean emailConfigured = emailConnectorService.getEmailConnector() != null; + + String userId; + boolean invited; + String tempPassword = null; + + if (emailConfigured && (request.tempPassword() == null || request.tempPassword().isBlank())) { + // Invite via email — no org needed for vendor (global role only) + // Create user with primaryEmail only; no org assignment + userId = logtoClient.createAndInviteUser(request.email(), null, null); + invited = true; + log.info("Invited vendor admin: {}", request.email()); + } else { + // Create with temporary password + tempPassword = request.tempPassword(); + if (tempPassword == null || tempPassword.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Temporary password required when email connector is not configured"); + } + accountService.validatePassword(tempPassword); + String username = request.email().substring(0, request.email().indexOf('@')); + userId = logtoClient.createUserWithPassword(username, tempPassword, null, null); + logtoClient.updateUserProfile(userId, Map.of("primaryEmail", request.email())); + invited = false; + log.info("Created vendor admin with credentials: {}", request.email()); + } + + logtoClient.assignGlobalRole(userId, roleId); + log.info("Assigned vendor role to user {}", userId); + + return new CreateAdminResponse(invited, invited ? null : tempPassword); + } + + public void removeAdmin(String userId, String requesterId) { + if (userId.equals(requesterId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove yourself as administrator"); + } + String roleId = getVendorRoleId(); + logtoClient.revokeGlobalRole(userId, roleId); + log.info("Revoked vendor role from user {}", userId); + } + + public void resetAdminPassword(String userId, String newPassword) { + accountService.validatePassword(newPassword); + logtoClient.updateUserPassword(userId, newPassword); + log.info("Reset password for vendor admin {}", userId); + } + + public void resetAdminMfa(String userId) { + logtoClient.deleteAllMfaVerifications(userId); + log.info("Reset MFA for vendor admin {}", userId); + } +}