From 6a79947dd1694e5e28747b3cd0a109527ca569ec Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 13:42:42 +0200 Subject: [PATCH 01/28] General: add admin user-management DTOs Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dto/AdminUserDetailDTO.java | 32 +++++++++++++++++ .../dto/AdminUserOverviewDTO.java | 35 +++++++++++++++++++ .../aet/usermanagement/dto/CreateUserDTO.java | 25 +++++++++++++ .../aet/usermanagement/dto/ImportUserDTO.java | 11 ++++++ .../aet/usermanagement/dto/UpdateUserDTO.java | 22 ++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserDetailDTO.java create mode 100644 src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserOverviewDTO.java create mode 100644 src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java create mode 100644 src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java create mode 100644 src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserDetailDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserDetailDTO.java new file mode 100644 index 0000000000..59678aba08 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserDetailDTO.java @@ -0,0 +1,32 @@ +package de.tum.cit.aet.usermanagement.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.usermanagement.constants.UserRole; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +/** Full admin-scoped user detail DTO returned by the view / edit page. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record AdminUserDetailDTO( + @NotNull UUID userId, + String firstName, + String lastName, + String email, + String avatar, + String universityId, + UserRole primaryRole, + UUID researchGroupId, + String researchGroupName, + String phoneNumber, + String gender, + String nationality, + LocalDate birthday, + String website, + String linkedinUrl, + String selectedLanguage, + Boolean aiFeaturesEnabled, + LocalDateTime createdAt, + LocalDateTime lastActivityAt +) {} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserOverviewDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserOverviewDTO.java new file mode 100644 index 0000000000..2c99da285d --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/AdminUserOverviewDTO.java @@ -0,0 +1,35 @@ +package de.tum.cit.aet.usermanagement.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.usermanagement.constants.UserRole; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Row-level admin user listing DTO for the "Manage Users" page table. + * + * @param userId the Keycloak / local user UUID + * @param firstName the user's given name + * @param lastName the user's family name + * @param email the user's email + * @param avatar URL of the user's avatar (optional) + * @param universityId the TUM identifier (optional) + * @param primaryRole the highest-privilege role across the user's research-group roles + * @param researchGroupId id of the user's primary research group (optional) + * @param researchGroupName name of that research group (optional) + * @param lastActivityAt last activity timestamp (optional) + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record AdminUserOverviewDTO( + @NotNull UUID userId, + String firstName, + String lastName, + String email, + String avatar, + String universityId, + UserRole primaryRole, + UUID researchGroupId, + String researchGroupName, + LocalDateTime lastActivityAt +) {} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java new file mode 100644 index 0000000000..792ee82028 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java @@ -0,0 +1,25 @@ +package de.tum.cit.aet.usermanagement.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +/** + * Request body for admin user creation. Required fields are sent to Keycloak; + * optional fields are stored only in the local DB. + */ +public record CreateUserDTO( + @NotBlank String firstName, + @NotBlank String lastName, + @NotBlank @Email String email, + @NotBlank @Size(min = 8) String password, + String universityId, + String phoneNumber, + String gender, + String nationality, + LocalDate birthday, + String website, + String linkedinUrl, + String selectedLanguage +) {} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java new file mode 100644 index 0000000000..e1dd8fb6ec --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.usermanagement.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +/** + * Request body for importing an existing Keycloak user by UUID. + * + * @param keycloakUserId the Keycloak user id to import + */ +public record ImportUserDTO(@NotNull UUID keycloakUserId) {} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java new file mode 100644 index 0000000000..f14bb9df2f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java @@ -0,0 +1,22 @@ +package de.tum.cit.aet.usermanagement.dto; + +import java.time.LocalDate; + +/** + * Request body for admin user edits. All fields optional. `email`, `password`, + * and `userId` are not updatable from this DTO. + */ +public record UpdateUserDTO( + String firstName, + String lastName, + String universityId, + String phoneNumber, + String gender, + String nationality, + LocalDate birthday, + String website, + String linkedinUrl, + String selectedLanguage, + Boolean aiFeaturesEnabled, + String avatar +) {} From 6707b14573e870f680d9f35506474ac50fe2bbc6 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 13:44:26 +0200 Subject: [PATCH 02/28] General: extend KeycloakUserService with delete, findById, and createWithPassword Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/KeycloakUserService.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java index 6d9e995a3b..4887e8cca1 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java @@ -235,6 +235,41 @@ public Optional findUserIdByEmail(String email) { return Optional.of(res.getFirst().getId()); } + /** + * Fetches a user from Keycloak by their UUID. + * + * @param userId the Keycloak user UUID + * @return the Keycloak user as a {@link KeycloakUserDTO}, or empty if no such user exists + */ + public Optional findKeycloakUserById(UUID userId) { + try { + UserRepresentation rep = keycloak.realm(realm).users().get(userId.toString()).toRepresentation(); + if (rep == null) { + return Optional.empty(); + } + String universityId = null; + Map> attributes = rep.getAttributes(); + if (attributes != null) { + List ldapIds = attributes.get("LDAP_ID"); + if (ldapIds != null && !ldapIds.isEmpty()) { + universityId = ldapIds.getFirst(); + } + } + return Optional.of( + new KeycloakUserDTO( + UUID.fromString(rep.getId()), + rep.getUsername(), + rep.getFirstName(), + rep.getLastName(), + rep.getEmail(), + universityId + ) + ); + } catch (jakarta.ws.rs.NotFoundException e) { + return Optional.empty(); + } + } + /** * Ensures a user exists in Keycloak for the given email (creates one if missing). *

@@ -271,6 +306,47 @@ public String ensureUser(OtpCompleteDTO body) { }); } + /** + * Creates a Keycloak user with the given identity fields and sets the initial password. + * If a user with the same email already exists, the existing Keycloak user id is returned + * and the password is updated. Used by the admin "Manage Users" create flow. + * + * @param email the user's email (also used as the Keycloak username) + * @param firstName the user's given name + * @param lastName the user's family name + * @param password the initial password (must be non-blank) + * @return the Keycloak user UUID + * @throws IllegalStateException if Keycloak user creation fails + */ + public UUID createUserWithPassword(String email, String firstName, String lastName, String password) { + String normalizedEmail = StringUtil.normalize(email, true); + String keycloakUserId = findUserIdByEmail(normalizedEmail).orElseGet(() -> { + UserRepresentation newUser = new UserRepresentation(); + newUser.setUsername(normalizedEmail); + newUser.setEmail(normalizedEmail); + newUser.setFirstName(StringUtil.normalize(firstName, false)); + newUser.setLastName(StringUtil.normalize(lastName, false)); + newUser.setEnabled(true); + newUser.setEmailVerified(true); + + try (Response resp = keycloak.realm(realm).users().create(newUser)) { + if (resp.getStatus() == 201 && resp.getLocation() != null) { + String path = resp.getLocation().getPath(); + return path.substring(path.lastIndexOf('/') + 1); + } + if (resp.getStatus() == 409) { + return findUserIdByEmail(normalizedEmail).orElseThrow(); + } + throw new IllegalStateException("Keycloak user create failed: status=" + resp.getStatus()); + } + }); + boolean passwordSet = setPassword(keycloakUserId, password); + if (!passwordSet) { + throw new IllegalStateException("Keycloak password set failed for userId=" + keycloakUserId); + } + return UUID.fromString(keycloakUserId); + } + /** * Sets the password for a Keycloak user. * @@ -308,4 +384,17 @@ public boolean setPassword(String userId, String newPassword) { public void logout(String userId) { keycloak.realm(realm).users().get(userId).logout(); } + + /** + * Deletes a user from Keycloak. Idempotent — returns silently if the user does not exist. + * + * @param userId the Keycloak user UUID + */ + public void deleteUser(UUID userId) { + try { + keycloak.realm(realm).users().get(userId.toString()).remove(); + } catch (jakarta.ws.rs.NotFoundException e) { + // 1) Idempotent delete: a missing Keycloak user is fine — local DB cleanup still proceeds. + } + } } From f8ad03f025dc1c019019b4f059163aa4e384c477 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 13:46:19 +0200 Subject: [PATCH 03/28] General: add UserRepository.findAllUsersForAdmin query Co-Authored-By: Claude Opus 4.7 (1M context) --- .../repository/UserRepository.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java b/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java index 1d9700888d..855aeae6ab 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java @@ -249,4 +249,61 @@ ORDER BY COALESCE(u.lastActivityAt, u.createdAt) ASC """ ) List findInactiveNonAdminUserIdsForWarning(@Param("warningDate") LocalDateTime warningDate); + + /** + * Finds users for the admin "Manage Users" listing. Optional filters for role + * and research-group; search matches firstName, lastName, email, or universityId. + * + * @param roles optional list of roles to include (matches if user has at least one of these) + * @param researchGroupIds optional list of research-group ids to include (matches via the user's roles) + * @param searchQuery optional search string + * @param pageable pagination configuration + * @return a page of matching users as {@link de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO} + */ + @Query( + """ + SELECT new de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO( + u.userId, + u.firstName, + u.lastName, + u.email, + u.avatar, + u.universityId, + CASE + WHEN EXISTS (SELECT 1 FROM UserResearchGroupRole rp WHERE rp.user.userId = u.userId AND rp.role = de.tum.cit.aet.usermanagement.constants.UserRole.ADMIN) + THEN de.tum.cit.aet.usermanagement.constants.UserRole.ADMIN + WHEN EXISTS (SELECT 1 FROM UserResearchGroupRole rp WHERE rp.user.userId = u.userId AND rp.role = de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR) + THEN de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR + WHEN EXISTS (SELECT 1 FROM UserResearchGroupRole rp WHERE rp.user.userId = u.userId AND rp.role = de.tum.cit.aet.usermanagement.constants.UserRole.EMPLOYEE) + THEN de.tum.cit.aet.usermanagement.constants.UserRole.EMPLOYEE + WHEN EXISTS (SELECT 1 FROM UserResearchGroupRole rp WHERE rp.user.userId = u.userId AND rp.role = de.tum.cit.aet.usermanagement.constants.UserRole.APPLICANT) + THEN de.tum.cit.aet.usermanagement.constants.UserRole.APPLICANT + ELSE NULL + END, + u.researchGroup.researchGroupId, + u.researchGroup.name, + u.lastActivityAt + ) + FROM User u + WHERE + (:roles IS NULL OR EXISTS ( + SELECT 1 FROM UserResearchGroupRole r WHERE r.user.userId = u.userId AND r.role IN :roles + )) + AND (:researchGroupIds IS NULL OR EXISTS ( + SELECT 1 FROM UserResearchGroupRole r WHERE r.user.userId = u.userId AND r.researchGroup.researchGroupId IN :researchGroupIds + )) + AND (:searchQuery IS NULL OR + u.firstName LIKE CONCAT('%', :searchQuery, '%') OR + u.lastName LIKE CONCAT('%', :searchQuery, '%') OR + u.email LIKE CONCAT('%', :searchQuery, '%') OR + u.universityId LIKE CONCAT('%', :searchQuery, '%') + ) + """ + ) + Page findAllUsersForAdmin( + @Param("roles") List roles, + @Param("researchGroupIds") List researchGroupIds, + @Param("searchQuery") String searchQuery, + Pageable pageable + ); } From 76463afc5cbc84b08bb4e76579169db549fe1a11 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 13:46:46 +0200 Subject: [PATCH 04/28] General: add UserRetentionService.deleteUserByAdmin for on-demand admin delete Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/retention/UserRetentionService.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/java/de/tum/cit/aet/core/retention/UserRetentionService.java b/src/main/java/de/tum/cit/aet/core/retention/UserRetentionService.java index ce75273fc4..fc6e7d4470 100644 --- a/src/main/java/de/tum/cit/aet/core/retention/UserRetentionService.java +++ b/src/main/java/de/tum/cit/aet/core/retention/UserRetentionService.java @@ -155,6 +155,34 @@ public void warnUserOfDataDeletion(LocalDateTime warningDate) { } } + /** + * Deletes a single user on demand from the admin "Manage Users" page. Reuses the + * existing per-category anonymisation helpers and finally removes the user row. + * Unlike {@link #processUserIdsList(List, LocalDateTime, boolean)}, this method + * does not skip admins — the caller is responsible for preventing self-delete. + * + * @param userId the user UUID to delete + */ + @Transactional + public void deleteUserByAdmin(UUID userId) { + Optional userOpt = userRepository.findWithResearchGroupRolesByUserId(userId); + if (userOpt.isEmpty()) { + log.info("Admin delete: userId={} not found, nothing to do", userId); + return; + } + User user = userOpt.get(); + RetentionCategory category = classify(user); + + if (category == RetentionCategory.PROFESSOR_OR_EMPLOYEE || category == RetentionCategory.SKIP_ADMIN) { + // 1) Treat admins identically to professor/employee for anonymisation purposes. + handleProfessorOrEmployee(user, false); + } else if (category == RetentionCategory.APPLICANT) { + handleApplicant(user, false); + } + // 2) UNKNOWN users have no FKs to anonymise; jump straight to general cleanup. + handleGeneralData(user, false); + } + // Helper methods for handling different categories private RetentionCategory classify(User user) { From 4bffa11c5810dd08623c54563dcda71a6fa8258b Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 13:50:55 +0200 Subject: [PATCH 05/28] General: add UserAdminService orchestrating Keycloak + DB user CRUD Co-Authored-By: Claude Opus 4.7 (1M context) --- .../de/tum/cit/aet/core/util/PageUtil.java | 5 + .../service/UserAdminService.java | 275 ++++++++++++++++++ .../service/UserAdminServiceTest.java | 111 +++++++ 3 files changed, 391 insertions(+) create mode 100644 src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java create mode 100644 src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java diff --git a/src/main/java/de/tum/cit/aet/core/util/PageUtil.java b/src/main/java/de/tum/cit/aet/core/util/PageUtil.java index f49c4c8fdc..b659dc0fb1 100644 --- a/src/main/java/de/tum/cit/aet/core/util/PageUtil.java +++ b/src/main/java/de/tum/cit/aet/core/util/PageUtil.java @@ -49,6 +49,11 @@ public enum ColumnMapping { */ SCHOOLS_ADMIN(Set.of("name", "abbreviation")), + /** + * Sortable columns used in admin "Manage Users" listings. + */ + USERS_ADMIN(Set.of("firstName", "lastName", "email", "universityId", "lastActivityAt")), + /** * Sortable columns used in applicant's application listings. */ diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java new file mode 100644 index 0000000000..0fcbc81626 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java @@ -0,0 +1,275 @@ +package de.tum.cit.aet.usermanagement.service; + +import de.tum.cit.aet.core.dto.PageDTO; +import de.tum.cit.aet.core.dto.SortDTO; +import de.tum.cit.aet.core.exception.EntityNotFoundException; +import de.tum.cit.aet.core.exception.OperationNotAllowedException; +import de.tum.cit.aet.core.retention.UserRetentionService; +import de.tum.cit.aet.core.service.CurrentUserService; +import de.tum.cit.aet.core.util.PageUtil; +import de.tum.cit.aet.core.util.StringUtil; +import de.tum.cit.aet.usermanagement.constants.UserRole; +import de.tum.cit.aet.usermanagement.domain.User; +import de.tum.cit.aet.usermanagement.dto.AdminUserDetailDTO; +import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; +import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; +import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; +import de.tum.cit.aet.usermanagement.dto.KeycloakUserDTO; +import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; +import de.tum.cit.aet.usermanagement.repository.UserRepository; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Orchestrates Keycloak and local-DB user management for admins. + * Provides list, detail, create, import, update, and delete operations + * used by the "Manage Users" admin page. + */ +@Service +@RequiredArgsConstructor +public class UserAdminService { + + private final UserRepository userRepository; + private final KeycloakUserService keycloakUserService; + private final UserService userService; + private final UserRetentionService userRetentionService; + private final CurrentUserService currentUserService; + + /** + * Returns a paginated, filterable, searchable list of users for the admin "Manage Users" view. + * + * @param pageDTO pagination configuration + * @param sortDTO sorting configuration + * @param roles optional filter on roles; null/empty means no filter + * @param researchGroupIds optional filter on research groups; null/empty means no filter + * @param searchQuery optional free-text search query (matched against name, email, universityId) + * @return a page of admin user overview rows + */ + public Page getAllUsersForAdmin( + PageDTO pageDTO, + SortDTO sortDTO, + List roles, + List researchGroupIds, + String searchQuery + ) { + Pageable pageable = PageUtil.createPageRequest(pageDTO, sortDTO, PageUtil.ColumnMapping.USERS_ADMIN, true); + return userRepository.findAllUsersForAdmin( + (roles == null || roles.isEmpty()) ? null : roles, + (researchGroupIds == null || researchGroupIds.isEmpty()) ? null : researchGroupIds, + StringUtil.normalizeSearchQuery(searchQuery), + pageable + ); + } + + /** + * Returns the full admin-scoped detail view for a single user. + * + * @param userId the user ID to look up + * @return the populated detail DTO + * @throws EntityNotFoundException if no user exists with the given ID + */ + public AdminUserDetailDTO getUserDetail(UUID userId) { + User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); + UserRole primaryRole = user.getResearchGroupRoles() == null + ? null + : user + .getResearchGroupRoles() + .stream() + .map(r -> r.getRole()) + .max(Comparator.comparingInt(UserAdminService::priority)) + .orElse(null); + UUID rgId = user.getResearchGroup() == null ? null : user.getResearchGroup().getResearchGroupId(); + String rgName = user.getResearchGroup() == null ? null : user.getResearchGroup().getName(); + return new AdminUserDetailDTO( + user.getUserId(), + user.getFirstName(), + user.getLastName(), + user.getEmail(), + user.getAvatar(), + user.getUniversityId(), + primaryRole, + rgId, + rgName, + user.getPhoneNumber(), + user.getGender(), + user.getNationality(), + user.getBirthday(), + user.getWebsite(), + user.getLinkedinUrl(), + user.getSelectedLanguage(), + user.isAiFeaturesEnabled(), + user.getCreatedAt(), + user.getLastActivityAt() + ); + } + + /** + * Creates a new Keycloak user with the given password, then provisions a matching + * local DB row and applies any optional DB-only fields. Keycloak is contacted first + * so that an upstream failure aborts the flow before any DB write. + * + * @param dto the create-user payload + * @return the new user's UUID + */ + @Transactional + public UUID create(CreateUserDTO dto) { + // 1) Keycloak first — failure aborts before any DB write. + UUID userId = keycloakUserService.createUserWithPassword(dto.email(), dto.firstName(), dto.lastName(), dto.password()); + // 2) Local DB upsert (creates user + assigns APPLICANT role if missing). + userService.upsertUser(userId.toString(), dto.email(), dto.firstName(), dto.lastName()); + // 3) Apply DB-only optional fields. + applyOptionalCreateFields(userId, dto); + return userId; + } + + /** + * Imports an existing Keycloak user into the local DB by their Keycloak UUID. + * + * @param dto the import payload containing the Keycloak user ID + * @return the imported user's UUID + * @throws EntityNotFoundException if no Keycloak user exists with the given ID + */ + @Transactional + public UUID importFromKeycloak(ImportUserDTO dto) { + KeycloakUserDTO kcUser = keycloakUserService + .findKeycloakUserById(dto.keycloakUserId()) + .orElseThrow(() -> EntityNotFoundException.forId("KeycloakUser", dto.keycloakUserId())); + userService.upsertUser(kcUser.id().toString(), kcUser.email(), kcUser.firstName(), kcUser.lastName()); + return kcUser.id(); + } + + /** + * Updates DB-only fields of an existing user. Email and userId are not mutable here; + * password updates go through a separate endpoint. + * + * @param userId the user ID to update + * @param dto the update payload (any null field is left untouched) + * @throws EntityNotFoundException if no user exists with the given ID + */ + @Transactional + public void update(UUID userId, UpdateUserDTO dto) { + User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); + if (dto.firstName() != null) { + user.setFirstName(dto.firstName()); + } + if (dto.lastName() != null) { + user.setLastName(dto.lastName()); + } + if (dto.universityId() != null) { + user.setUniversityId(dto.universityId()); + } + if (dto.phoneNumber() != null) { + user.setPhoneNumber(dto.phoneNumber()); + } + if (dto.gender() != null) { + user.setGender(dto.gender()); + } + if (dto.nationality() != null) { + user.setNationality(dto.nationality()); + } + if (dto.birthday() != null) { + user.setBirthday(dto.birthday()); + } + if (dto.website() != null) { + user.setWebsite(dto.website()); + } + if (dto.linkedinUrl() != null) { + user.setLinkedinUrl(dto.linkedinUrl()); + } + if (dto.selectedLanguage() != null) { + user.setSelectedLanguage(dto.selectedLanguage()); + } + if (dto.aiFeaturesEnabled() != null) { + user.setAiFeaturesEnabled(dto.aiFeaturesEnabled()); + } + if (dto.avatar() != null) { + user.setAvatar(dto.avatar()); + } + userRepository.save(user); + } + + /** + * Deletes a user from Keycloak and anonymises their local-DB references. + * An admin cannot delete their own account. + * + * @param userId the user ID to delete + * @throws OperationNotAllowedException if the caller targets their own account + */ + @Transactional + public void delete(UUID userId) { + UUID currentUserId = currentUserService.getUserId(); + if (userId.equals(currentUserId)) { + throw new OperationNotAllowedException("Admins cannot delete their own account."); + } + keycloakUserService.deleteUser(userId); + userRetentionService.deleteUserByAdmin(userId); + } + + /** + * Applies the optional DB-only fields supplied during user creation. + * Loads the freshly upserted user and writes only non-null values. + * + * @param userId the newly created user's ID + * @param dto the create payload (optional fields read here) + */ + private void applyOptionalCreateFields(UUID userId, CreateUserDTO dto) { + User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); + boolean changed = false; + if (dto.universityId() != null) { + user.setUniversityId(dto.universityId()); + changed = true; + } + if (dto.phoneNumber() != null) { + user.setPhoneNumber(dto.phoneNumber()); + changed = true; + } + if (dto.gender() != null) { + user.setGender(dto.gender()); + changed = true; + } + if (dto.nationality() != null) { + user.setNationality(dto.nationality()); + changed = true; + } + if (dto.birthday() != null) { + user.setBirthday(dto.birthday()); + changed = true; + } + if (dto.website() != null) { + user.setWebsite(dto.website()); + changed = true; + } + if (dto.linkedinUrl() != null) { + user.setLinkedinUrl(dto.linkedinUrl()); + changed = true; + } + if (dto.selectedLanguage() != null) { + user.setSelectedLanguage(dto.selectedLanguage()); + changed = true; + } + if (changed) { + userRepository.save(user); + } + } + + /** + * Ranks roles so the highest-privilege role is selected as the user's primary role. + * + * @param role the role to rank + * @return integer priority (higher means more privileged) + */ + private static int priority(UserRole role) { + return switch (role) { + case ADMIN -> 3; + case PROFESSOR -> 2; + case EMPLOYEE -> 1; + case APPLICANT -> 0; + }; + } +} diff --git a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java new file mode 100644 index 0000000000..393e7bdfd9 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java @@ -0,0 +1,111 @@ +package de.tum.cit.aet.usermanagement.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import de.tum.cit.aet.core.exception.EntityNotFoundException; +import de.tum.cit.aet.core.exception.OperationNotAllowedException; +import de.tum.cit.aet.core.retention.UserRetentionService; +import de.tum.cit.aet.core.service.CurrentUserService; +import de.tum.cit.aet.usermanagement.domain.User; +import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; +import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; +import de.tum.cit.aet.usermanagement.dto.KeycloakUserDTO; +import de.tum.cit.aet.usermanagement.repository.UserRepository; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UserAdminServiceTest { + + @Mock + UserRepository userRepository; + + @Mock + KeycloakUserService keycloakUserService; + + @Mock + UserService userService; + + @Mock + UserRetentionService userRetentionService; + + @Mock + CurrentUserService currentUserService; + + @InjectMocks + UserAdminService userAdminService; + + @Test + void shouldCreateKeycloakUserThenSyncDb() { + UUID newId = UUID.randomUUID(); + when(keycloakUserService.createUserWithPassword(eq("a@b.com"), eq("Alice"), eq("Apple"), eq("hunter2!Long"))) + .thenReturn(newId); + User user = new User(); + user.setUserId(newId); + when(userService.upsertUser(eq(newId.toString()), eq("a@b.com"), eq("Alice"), eq("Apple"))).thenReturn(user); + when(userRepository.findById(newId)).thenReturn(Optional.of(user)); + + UUID created = userAdminService.create( + new CreateUserDTO("Alice", "Apple", "a@b.com", "hunter2!Long", null, null, null, null, null, null, null, null) + ); + + assertThat(created).isEqualTo(newId); + verify(keycloakUserService).createUserWithPassword("a@b.com", "Alice", "Apple", "hunter2!Long"); + verify(userService).upsertUser(newId.toString(), "a@b.com", "Alice", "Apple"); + } + + @Test + void shouldImportExistingKeycloakUser() { + UUID kcId = UUID.randomUUID(); + when(keycloakUserService.findKeycloakUserById(kcId)).thenReturn( + Optional.of(new KeycloakUserDTO(kcId, "alice", "Alice", "Apple", "a@b.com", null)) + ); + User user = new User(); + user.setUserId(kcId); + when(userService.upsertUser(eq(kcId.toString()), eq("a@b.com"), eq("Alice"), eq("Apple"))).thenReturn(user); + + UUID imported = userAdminService.importFromKeycloak(new ImportUserDTO(kcId)); + + assertThat(imported).isEqualTo(kcId); + } + + @Test + void shouldRejectImportWhenKeycloakUuidMissing() { + UUID kcId = UUID.randomUUID(); + when(keycloakUserService.findKeycloakUserById(kcId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userAdminService.importFromKeycloak(new ImportUserDTO(kcId))).isInstanceOf(EntityNotFoundException.class); + } + + @Test + void shouldRejectSelfDelete() { + UUID adminId = UUID.randomUUID(); + when(currentUserService.getUserId()).thenReturn(adminId); + + assertThatThrownBy(() -> userAdminService.delete(adminId)).isInstanceOf(OperationNotAllowedException.class); + verifyNoInteractions(keycloakUserService); + verifyNoInteractions(userRetentionService); + } + + @Test + void shouldDeleteFromKeycloakThenAnonymiseDb() { + UUID adminId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + when(currentUserService.getUserId()).thenReturn(adminId); + + userAdminService.delete(targetId); + + verify(keycloakUserService).deleteUser(targetId); + verify(userRetentionService).deleteUserByAdmin(targetId); + } +} From 58809c9da1a62e733cd840cfec4882f0a72d7e08 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 17:02:38 +0200 Subject: [PATCH 06/28] General: add UserAdminResource for admin user CRUD endpoints Co-Authored-By: Claude Opus 4.7 (1M context) --- .../usermanagement/web/UserAdminResource.java | 137 ++++++++++++++++ .../web/rest/UserAdminResourceTest.java | 146 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java create mode 100644 src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java diff --git a/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java b/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java new file mode 100644 index 0000000000..6075411736 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java @@ -0,0 +1,137 @@ +package de.tum.cit.aet.usermanagement.web; + +import de.tum.cit.aet.core.dto.PageDTO; +import de.tum.cit.aet.core.dto.SortDTO; +import de.tum.cit.aet.core.security.annotations.Admin; +import de.tum.cit.aet.usermanagement.constants.UserRole; +import de.tum.cit.aet.usermanagement.dto.AdminUserDetailDTO; +import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; +import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; +import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; +import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; +import de.tum.cit.aet.usermanagement.service.UserAdminService; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller backing the admin "Manage Users" page. + * Exposes paginated listing, detail, create, import, update, and delete endpoints + * — all restricted to administrators. + */ +@Slf4j +@RestController +@RequestMapping("/api/admin/users") +@RequiredArgsConstructor +public class UserAdminResource { + + private final UserAdminService userAdminService; + + /** + * {@code GET /api/admin/users} : Returns a paginated, filterable, searchable list of users for admins. + * + * @param pageDTO pagination configuration + * @param sortDTO sorting configuration + * @param roles optional role filter + * @param researchGroupIds optional research-group filter + * @param searchQuery optional free-text search query + * @return the {@link ResponseEntity} with status {@code 200 (OK)} containing a page of {@link AdminUserOverviewDTO} + */ + @Admin + @GetMapping + public ResponseEntity> getAllUsers( + @ParameterObject @Valid @ModelAttribute PageDTO pageDTO, + @ParameterObject @Valid @ModelAttribute SortDTO sortDTO, + @RequestParam(required = false) List roles, + @RequestParam(required = false) List researchGroupIds, + @RequestParam(required = false) String searchQuery + ) { + log.info("GET /api/admin/users - Fetching admin user list"); + return ResponseEntity.ok(userAdminService.getAllUsersForAdmin(pageDTO, sortDTO, roles, researchGroupIds, searchQuery)); + } + + /** + * {@code GET /api/admin/users/{userId}} : Returns the full admin-scoped detail view for a single user. + * + * @param userId the user id to look up + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and the user detail + */ + @Admin + @GetMapping("/{userId}") + public ResponseEntity getUser(@PathVariable UUID userId) { + log.info("GET /api/admin/users/{} - Fetching admin user detail", userId); + return ResponseEntity.ok(userAdminService.getUserDetail(userId)); + } + + /** + * {@code POST /api/admin/users} : Creates a new Keycloak user and provisions a matching local-DB row. + * + * @param dto the create-user payload + * @return the {@link ResponseEntity} with status {@code 201 (Created)} and the created user detail + */ + @Admin + @PostMapping + public ResponseEntity createUser(@RequestBody @Valid CreateUserDTO dto) { + log.info("POST /api/admin/users - Creating user email={}", dto.email()); + UUID userId = userAdminService.create(dto); + return ResponseEntity.status(201).body(userAdminService.getUserDetail(userId)); + } + + /** + * {@code POST /api/admin/users/import} : Imports an existing Keycloak user into the local DB by Keycloak UUID. + * + * @param dto the import payload containing the Keycloak user id + * @return the {@link ResponseEntity} with status {@code 201 (Created)} and the imported user detail + */ + @Admin + @PostMapping("/import") + public ResponseEntity importUser(@RequestBody @Valid ImportUserDTO dto) { + log.info("POST /api/admin/users/import - Importing keycloakUserId={}", dto.keycloakUserId()); + UUID userId = userAdminService.importFromKeycloak(dto); + return ResponseEntity.status(201).body(userAdminService.getUserDetail(userId)); + } + + /** + * {@code PUT /api/admin/users/{userId}} : Updates DB-only fields of an existing user. + * + * @param userId the user id to update + * @param dto the update payload (any null field is left untouched) + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and the refreshed user detail + */ + @Admin + @PutMapping("/{userId}") + public ResponseEntity updateUser(@PathVariable UUID userId, @RequestBody @Valid UpdateUserDTO dto) { + log.info("PUT /api/admin/users/{} - Updating user", userId); + userAdminService.update(userId, dto); + return ResponseEntity.ok(userAdminService.getUserDetail(userId)); + } + + /** + * {@code DELETE /api/admin/users/{userId}} : Deletes a user from Keycloak and anonymises their local-DB references. + * + * @param userId the user id to delete + * @return the {@link ResponseEntity} with status {@code 204 (No Content)} + */ + @Admin + @DeleteMapping("/{userId}") + public ResponseEntity deleteUser(@PathVariable UUID userId) { + log.info("DELETE /api/admin/users/{} - Deleting user", userId); + userAdminService.delete(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java new file mode 100644 index 0000000000..18c5f67364 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java @@ -0,0 +1,146 @@ +package de.tum.cit.aet.usermanagement.web.rest; + +import static org.assertj.core.api.Assertions.assertThat; + +import de.tum.cit.aet.AbstractResourceTest; +import de.tum.cit.aet.usermanagement.domain.ResearchGroup; +import de.tum.cit.aet.usermanagement.domain.User; +import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; +import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; +import de.tum.cit.aet.usermanagement.repository.UserRepository; +import de.tum.cit.aet.utility.DatabaseCleaner; +import de.tum.cit.aet.utility.MvcTestClient; +import de.tum.cit.aet.utility.PageResponse; +import de.tum.cit.aet.utility.security.JwtPostProcessors; +import de.tum.cit.aet.utility.testdata.ResearchGroupTestData; +import de.tum.cit.aet.utility.testdata.UserTestData; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import tools.jackson.core.type.TypeReference; + +/** + * Integration tests for {@link de.tum.cit.aet.usermanagement.web.UserAdminResource}. + * Verifies admin success and professor 403 paths plus validation/auth edge cases. + */ +class UserAdminResourceTest extends AbstractResourceTest { + + @Autowired + UserRepository userRepository; + + @Autowired + ResearchGroupRepository researchGroupRepository; + + @Autowired + DatabaseCleaner databaseCleaner; + + @Autowired + MvcTestClient api; + + ResearchGroup researchGroup; + User adminUser; + User professor; + + @BeforeEach + void setup() { + databaseCleaner.clean(); + api.withoutPostProcessors(); + + researchGroup = ResearchGroupTestData.savedAll( + researchGroupRepository, + "Prof. Doe", + "Algorithms Group", + "ALG", + "Munich", + "We do cool stuff", + "alg@example.com", + "80333", + "CIT", + "Arcisstr. 21", + "https://alg.tum.de", + "ACTIVE" + ); + + adminUser = UserTestData.saveAdmin(userRepository); + professor = UserTestData.saveProfessor(researchGroup, userRepository); + } + + @Nested + class GetAllUsers { + + @Test + void shouldReturnUsersForAdmin() { + PageResponse page = api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .getAndRead("/api/admin/users", Map.of("pageNumber", "0", "pageSize", "10"), new TypeReference<>() {}, 200); + + assertThat(page.content()).isNotNull(); + assertThat(page.totalElements()).isGreaterThanOrEqualTo(1); + } + + @Test + void shouldRejectProfessor() { + api + .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) + .getAndRead("/api/admin/users", Map.of("pageNumber", "0", "pageSize", "10"), new TypeReference<>() {}, 403); + } + } + + @Nested + class GetUserById { + + @Test + void shouldReturn404ForUnknownUser() { + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .getAndRead("/api/admin/users/" + UUID.randomUUID(), null, Void.class, 404); + } + } + + @Nested + class DeleteUser { + + @Test + void shouldRejectSelfDelete() { + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .deleteAndRead("/api/admin/users/" + adminUser.getUserId(), null, Void.class, 400); + } + + @Test + void shouldRejectProfessor() { + api + .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) + .deleteAndRead("/api/admin/users/" + UUID.randomUUID(), null, Void.class, 403); + } + } + + @Nested + class CreateUser { + + @Test + void shouldRejectInvalidPayload() { + // Empty body — required @NotBlank fields (firstName, lastName, email, password) are missing. + Map emptyPayload = new HashMap<>(); + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .postAndRead("/api/admin/users", emptyPayload, Void.class, 400); + } + + @Test + void shouldRejectProfessor() { + Map payload = new HashMap<>(); + payload.put("firstName", "New"); + payload.put("lastName", "User"); + payload.put("email", "new.user@tum.de"); + payload.put("password", "supersecure1"); + api + .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) + .postAndRead("/api/admin/users", payload, Void.class, 403); + } + } +} From 8199d628893c24e2e92af96377b5c3045270e2f3 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 17:56:32 +0200 Subject: [PATCH 07/28] General: left-join research group in admin user listing so admins appear Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cit/aet/usermanagement/repository/UserRepository.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java b/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java index 855aeae6ab..2ae1d20b39 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/repository/UserRepository.java @@ -280,11 +280,12 @@ WHEN EXISTS (SELECT 1 FROM UserResearchGroupRole rp WHERE rp.user.userId = u.use THEN de.tum.cit.aet.usermanagement.constants.UserRole.APPLICANT ELSE NULL END, - u.researchGroup.researchGroupId, - u.researchGroup.name, + rg.researchGroupId, + rg.name, u.lastActivityAt ) FROM User u + LEFT JOIN u.researchGroup rg WHERE (:roles IS NULL OR EXISTS ( SELECT 1 FROM UserResearchGroupRole r WHERE r.user.userId = u.userId AND r.role IN :roles From a0f08f6b30f029e039dffbf3148bc6ccc6ccb660 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 18:04:02 +0200 Subject: [PATCH 08/28] General: let admins pick a role (PROFESSOR or EMPLOYEE) when adding members Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dto/AddMembersToResearchGroupDTO.java | 13 +++- .../service/ResearchGroupService.java | 26 +++++--- .../web/ResearchGroupResource.java | 2 +- .../web/rest/ResearchGroupResourceTest.java | 59 +++++++++++++++++-- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/AddMembersToResearchGroupDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/AddMembersToResearchGroupDTO.java index d3f70c0c67..878643782b 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/dto/AddMembersToResearchGroupDTO.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/AddMembersToResearchGroupDTO.java @@ -1,9 +1,20 @@ package de.tum.cit.aet.usermanagement.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.usermanagement.constants.UserRole; import jakarta.validation.constraints.NotEmpty; import java.util.List; import java.util.UUID; @JsonInclude(JsonInclude.Include.NON_NULL) -public record AddMembersToResearchGroupDTO(@NotEmpty List keycloakUsers, UUID researchGroupId) {} +public record AddMembersToResearchGroupDTO(@NotEmpty List keycloakUsers, UUID researchGroupId, UserRole role) { + /** + * Returns the role to assign to the added members, defaulting to {@link UserRole#EMPLOYEE} + * when no role was specified by the caller (back-compat). + * + * @return the resolved {@link UserRole}, never {@code null} + */ + public UserRole roleOrDefault() { + return role == null ? UserRole.EMPLOYEE : role; + } +} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/ResearchGroupService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/ResearchGroupService.java index ac0708e3e5..93be8054a6 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/ResearchGroupService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/ResearchGroupService.java @@ -8,6 +8,7 @@ import de.tum.cit.aet.core.exception.AlreadyMemberOfResearchGroupException; import de.tum.cit.aet.core.exception.BadRequestException; import de.tum.cit.aet.core.exception.EntityNotFoundException; +import de.tum.cit.aet.core.exception.InvalidParameterException; import de.tum.cit.aet.core.exception.ResourceAlreadyExistsException; import de.tum.cit.aet.core.service.CurrentUserService; import de.tum.cit.aet.core.util.HtmlSanitizer; @@ -592,16 +593,25 @@ public void createEmployeeResearchGroupRequest(EmployeeResearchGroupRequestDTO r * Verifies that the current user is an admin or a member of the target research group * before proceeding. For each provided Keycloak user, this method ensures they exist in * the local database. If a user does not exist locally, they are created. The user is then - * assigned to the specified research group. An email notification is sent only if the user - * is newly created or if their research group assignment has changed. + * assigned to the specified research group with the role specified on the DTO (defaulting + * to {@link UserRole#EMPLOYEE} when none is supplied). An email notification is sent only + * if the user is newly created or if their research group assignment has changed. * - * @param keycloakUsers A list of {@link KeycloakUserDTO} representing the users to be added. - * @param researchGroupId The ID of the research group to which the members will be added. - * If null, the current user's group is used (if they are a professor). - * @throws AccessDeniedException if the current user is not a member of the target group + * @param dto the payload describing which users to add, the target research group and the + * role to assign to each user + * @throws AccessDeniedException if the current user is not a member of the target group + * @throws InvalidParameterException if the requested role is neither {@link UserRole#EMPLOYEE} + * nor {@link UserRole#PROFESSOR} */ @Transactional - public void addMembersToResearchGroup(List keycloakUsers, UUID researchGroupId) { + public void addMembersToResearchGroup(AddMembersToResearchGroupDTO dto) { + UserRole targetRole = dto.roleOrDefault(); + if (targetRole != UserRole.EMPLOYEE && targetRole != UserRole.PROFESSOR) { + throw new InvalidParameterException("Only EMPLOYEE or PROFESSOR roles can be assigned via add-members."); + } + + UUID researchGroupId = dto.researchGroupId(); + List keycloakUsers = dto.keycloakUsers(); UUID targetGroupId = researchGroupId != null ? researchGroupId : currentUserService.getResearchGroupIdIfMember(); // Throws AccessDeniedException if the current user is neither an admin nor a member of the target group currentUserService.isAdminOrMemberOf(targetGroupId); @@ -637,7 +647,7 @@ public void addMembersToResearchGroup(List keycloakUsers, UUID userRepository.save(user); // Ensure the user has a role in the research group - ensureUserRoleInGroup(user, researchGroup, UserRole.EMPLOYEE); + ensureUserRoleInGroup(user, researchGroup, targetRole); // Send notification email because the group was newly assigned or changed sendWelcomeToResearchGroupEmail(user, researchGroup); diff --git a/src/main/java/de/tum/cit/aet/usermanagement/web/ResearchGroupResource.java b/src/main/java/de/tum/cit/aet/usermanagement/web/ResearchGroupResource.java index 75dc049204..6199371e74 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/web/ResearchGroupResource.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/web/ResearchGroupResource.java @@ -302,7 +302,7 @@ public ResponseEntity withdrawResearchGroup(@PathVariable UUID @PostMapping("/members") public ResponseEntity addMembersToResearchGroup(@Valid @RequestBody AddMembersToResearchGroupDTO dto) { log.info("POST /api/research-groups/members - adding {} members", dto.keycloakUsers().size()); - researchGroupService.addMembersToResearchGroup(dto.keycloakUsers(), dto.researchGroupId()); + researchGroupService.addMembersToResearchGroup(dto); return ResponseEntity.noContent().build(); } } diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java index cdf817bac1..695151650e 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java @@ -10,6 +10,7 @@ import de.tum.cit.aet.notification.service.AsyncEmailSender; import de.tum.cit.aet.notification.service.mail.Email; import de.tum.cit.aet.usermanagement.constants.ResearchGroupState; +import de.tum.cit.aet.usermanagement.constants.UserRole; import de.tum.cit.aet.usermanagement.domain.Department; import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.School; @@ -1015,7 +1016,7 @@ class AddMembersToResearchGroup { void addMembersToResearchGroupAsProfessorAddsMembers() { User userToAdd = UserTestData.createUserWithoutResearchGroup(userRepository, "add.me@tum.de", "Add", "Me", "add123"); KeycloakUserDTO kcUser = UserTestData.kcUserFrom(userToAdd); - AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), researchGroup.getResearchGroupId()); + AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), researchGroup.getResearchGroupId(), null); api .with(JwtPostProcessors.jwtUser(researchGroupUser.getUserId(), "ROLE_PROFESSOR")) @@ -1029,7 +1030,7 @@ void addMembersToResearchGroupAsProfessorAddsMembers() { void addMembersToResearchGroupAsAdminAddsMembers() { User userToAdd = UserTestData.createUserWithoutResearchGroup(userRepository, "add.admin@tum.de", "Add", "Admin", "adm999"); KeycloakUserDTO kcUser = UserTestData.kcUserFrom(userToAdd); - AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), researchGroup.getResearchGroupId()); + AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), researchGroup.getResearchGroupId(), null); User adminUser = UserTestData.saveAdmin(userRepository); api @@ -1044,7 +1045,7 @@ void addMembersToResearchGroupAsAdminAddsMembers() { void addMembersToResearchGroupWithNonExistentUserCreatesUser() { UUID randomId = UUID.randomUUID(); KeycloakUserDTO kcUser = UserTestData.newKeycloakUser(randomId, null, "New", "User", "new.user@tum.de", "ab12abc"); - AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), researchGroup.getResearchGroupId()); + AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), researchGroup.getResearchGroupId(), null); api .with(JwtPostProcessors.jwtUser(researchGroupUser.getUserId(), "ROLE_PROFESSOR")) @@ -1061,7 +1062,7 @@ void addMembersToResearchGroupWithNonExistentGroupThrowsAccessDenied() { User userToAdd = UserTestData.createUserWithoutResearchGroup(userRepository, "add.fail@tum.de", "Add", "Fail", "fail123"); KeycloakUserDTO kcUser = UserTestData.kcUserFrom(userToAdd); UUID nonExistentGroupId = UUID.randomUUID(); - AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), nonExistentGroupId); + AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), nonExistentGroupId, null); // The authorization check now runs before the entity lookup, so a non-existent // group ID results in 403 (user is not a member) rather than 404. @@ -1082,7 +1083,11 @@ void addMembersToResearchGroupAsAdminAddsMultipleMembers() { KeycloakUserDTO kcA = UserTestData.kcUserFrom(userA); KeycloakUserDTO kcB = UserTestData.kcUserFrom(userB); - AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcA, kcB), researchGroup.getResearchGroupId()); + AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO( + List.of(kcA, kcB), + researchGroup.getResearchGroupId(), + null + ); User adminUser = UserTestData.saveAdmin(userRepository); api @@ -1096,5 +1101,49 @@ void addMembersToResearchGroupAsAdminAddsMultipleMembers() { assertThat(ua.getResearchGroup().getResearchGroupId()).isEqualTo(researchGroup.getResearchGroupId()); assertThat(ub.getResearchGroup().getResearchGroupId()).isEqualTo(researchGroup.getResearchGroupId()); } + + @Test + void shouldAssignProfessorRoleWhenSpecified() { + User userToAdd = UserTestData.createUserWithoutResearchGroup(userRepository, "prof.add@tum.de", "Prof", "Add", "prof001"); + KeycloakUserDTO kcUser = UserTestData.kcUserFrom(userToAdd); + AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO( + List.of(kcUser), + researchGroup.getResearchGroupId(), + UserRole.PROFESSOR + ); + User adminUser = UserTestData.saveAdmin(userRepository); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .postAndRead(API_BASE_PATH + "/members", dto, Void.class, 204); + + User updatedUser = userRepository.findById(userToAdd.getUserId()).orElseThrow(); + assertThat(userResearchGroupRoleRepository.findAllByUser(updatedUser)) + .anyMatch(role -> + role.getRole() == UserRole.PROFESSOR && + role.getResearchGroup() != null && + role.getResearchGroup().getResearchGroupId().equals(researchGroup.getResearchGroupId()) + ); + } + + @Test + void shouldDefaultToEmployeeWhenRoleNull() { + User userToAdd = UserTestData.createUserWithoutResearchGroup(userRepository, "emp.default@tum.de", "Emp", "Default", "emp001"); + KeycloakUserDTO kcUser = UserTestData.kcUserFrom(userToAdd); + AddMembersToResearchGroupDTO dto = new AddMembersToResearchGroupDTO(List.of(kcUser), researchGroup.getResearchGroupId(), null); + User adminUser = UserTestData.saveAdmin(userRepository); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .postAndRead(API_BASE_PATH + "/members", dto, Void.class, 204); + + User updatedUser = userRepository.findById(userToAdd.getUserId()).orElseThrow(); + assertThat(userResearchGroupRoleRepository.findAllByUser(updatedUser)) + .anyMatch(role -> + role.getRole() == UserRole.EMPLOYEE && + role.getResearchGroup() != null && + role.getResearchGroup().getResearchGroupId().equals(researchGroup.getResearchGroupId()) + ); + } } } From 7827f42affaeef7e3f57d839c0f3e79ff0a0221f Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 18:05:38 +0200 Subject: [PATCH 09/28] General: regenerate OpenAPI client for admin user-management endpoints Co-Authored-By: Claude Opus 4.7 (1M context) --- openapi/openapi.yaml | 216 ++++++++++++++++++ .../app/generated/.openapi-generator/FILES | 8 + .../generated/api/user-admin-resource-api.ts | 122 ++++++++++ .../api/user-admin-resource-resources.ts | 84 +++++++ .../add-members-to-research-group-dto.ts | 13 ++ .../generated/model/admin-user-detail-dto.ts | 44 ++++ .../model/admin-user-overview-dto.ts | 35 +++ .../app/generated/model/create-user-dto.ts | 25 ++ .../app/generated/model/import-user-dto.ts | 14 ++ .../model/page-admin-user-overview-dto.ts | 27 +++ .../app/generated/model/update-user-dto.ts | 25 ++ 11 files changed, 613 insertions(+) create mode 100644 src/main/webapp/app/generated/api/user-admin-resource-api.ts create mode 100644 src/main/webapp/app/generated/api/user-admin-resource-resources.ts create mode 100644 src/main/webapp/app/generated/model/admin-user-detail-dto.ts create mode 100644 src/main/webapp/app/generated/model/admin-user-overview-dto.ts create mode 100644 src/main/webapp/app/generated/model/create-user-dto.ts create mode 100644 src/main/webapp/app/generated/model/import-user-dto.ts create mode 100644 src/main/webapp/app/generated/model/page-admin-user-overview-dto.ts create mode 100644 src/main/webapp/app/generated/model/update-user-dto.ts diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 2be93801a4..e3217bba8b 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -79,6 +79,126 @@ paths: content: application/json: schema: {$ref: '#/components/schemas/AdminExportTaskDTO'} + /api/admin/users: + get: + tags: [user-admin-resource] + operationId: getAllUsers + parameters: + - name: pageSize + in: query + required: false + schema: {type: integer, format: int32, minimum: 1} + - name: pageNumber + in: query + required: false + schema: {type: integer, format: int32, minimum: 0} + - name: sortBy + in: query + required: false + schema: {type: string} + - name: direction + in: query + required: false + schema: + type: string + enum: [ASC, DESC] + - name: roles + in: query + required: false + schema: + type: array + items: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] + - name: researchGroupIds + in: query + required: false + schema: + type: array + items: {type: string, format: uuid} + - name: searchQuery + in: query + required: false + schema: {type: string} + responses: + '200': + description: OK + content: + application/json: + schema: {$ref: '#/components/schemas/PageAdminUserOverviewDTO'} + post: + tags: [user-admin-resource] + operationId: createUser + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/CreateUserDTO'} + required: true + responses: + '200': + description: OK + content: + application/json: + schema: {$ref: '#/components/schemas/AdminUserDetailDTO'} + /api/admin/users/import: + post: + tags: [user-admin-resource] + operationId: importUser + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/ImportUserDTO'} + required: true + responses: + '200': + description: OK + content: + application/json: + schema: {$ref: '#/components/schemas/AdminUserDetailDTO'} + /api/admin/users/{userId}: + get: + tags: [user-admin-resource] + operationId: getUser + parameters: + - name: userId + in: path + required: true + schema: {type: string, format: uuid} + responses: + '200': + description: OK + content: + application/json: + schema: {$ref: '#/components/schemas/AdminUserDetailDTO'} + put: + tags: [user-admin-resource] + operationId: updateUser + parameters: + - name: userId + in: path + required: true + schema: {type: string, format: uuid} + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/UpdateUserDTO'} + required: true + responses: + '200': + description: OK + content: + application/json: + schema: {$ref: '#/components/schemas/AdminUserDetailDTO'} + delete: + tags: [user-admin-resource] + operationId: deleteUser + parameters: + - name: userId + in: path + required: true + schema: {type: string, format: uuid} + responses: + '200': {description: OK} /api/ai/analyze-job-description: post: tags: [ai-resource] @@ -2650,6 +2770,9 @@ components: items: {$ref: '#/components/schemas/KeycloakUserDTO'} minItems: 1 researchGroupId: {type: string, format: uuid} + role: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] required: [keycloakUsers] AdminExportTaskDTO: type: object @@ -2678,6 +2801,47 @@ components: APPLICATIONS_ONLY] userResearchGroupRoles: {$ref: '#/components/schemas/Counts'} users: {$ref: '#/components/schemas/Counts'} + AdminUserDetailDTO: + type: object + properties: + aiFeaturesEnabled: {type: boolean} + avatar: {type: string} + birthday: {type: string, format: date} + createdAt: {type: string, format: date-time} + email: {type: string} + firstName: {type: string} + gender: {type: string} + lastActivityAt: {type: string, format: date-time} + lastName: {type: string} + linkedinUrl: {type: string} + nationality: {type: string} + phoneNumber: {type: string} + primaryRole: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] + researchGroupId: {type: string, format: uuid} + researchGroupName: {type: string} + selectedLanguage: {type: string} + universityId: {type: string} + userId: {type: string, format: uuid} + website: {type: string} + required: [userId] + AdminUserOverviewDTO: + type: object + properties: + avatar: {type: string} + email: {type: string} + firstName: {type: string} + lastActivityAt: {type: string, format: date-time} + lastName: {type: string} + primaryRole: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] + researchGroupId: {type: string, format: uuid} + researchGroupName: {type: string} + universityId: {type: string} + userId: {type: string, format: uuid} + required: [userId] AiFeatureStatusDTO: type: object properties: @@ -2917,6 +3081,22 @@ components: items: {$ref: '#/components/schemas/SlotInput'} minItems: 1 required: [slots] + CreateUserDTO: + type: object + properties: + birthday: {type: string, format: date} + email: {type: string, format: email, minLength: 1} + firstName: {type: string, minLength: 1} + gender: {type: string} + lastName: {type: string, minLength: 1} + linkedinUrl: {type: string} + nationality: {type: string} + password: {type: string, maxLength: 2147483647, minLength: 8} + phoneNumber: {type: string} + selectedLanguage: {type: string} + universityId: {type: string} + website: {type: string} + required: [email, firstName, lastName, password] CreatedJobDTO: type: object properties: @@ -3113,6 +3293,11 @@ components: sizeBytes: {type: integer, format: int64} uploadedById: {type: string, format: uuid} url: {type: string} + ImportUserDTO: + type: object + properties: + keycloakUserId: {type: string, format: uuid} + required: [keycloakUserId] InternalCommentDTO: type: object properties: @@ -3433,6 +3618,22 @@ components: type: string enum: [LOGIN, REGISTER] required: [code, email, purpose] + PageAdminUserOverviewDTO: + type: object + properties: + content: + type: array + items: {$ref: '#/components/schemas/AdminUserOverviewDTO'} + empty: {type: boolean} + first: {type: boolean} + last: {type: boolean} + number: {type: integer, format: int32} + numberOfElements: {type: integer, format: int32} + pageable: {$ref: '#/components/schemas/PageableObject'} + size: {type: integer, format: int32} + sort: {$ref: '#/components/schemas/SortObject'} + totalElements: {type: integer, format: int64} + totalPages: {type: integer, format: int32} PageApplicationOverviewDTO: type: object properties: @@ -3759,6 +3960,21 @@ components: properties: location: {type: string, minLength: 1} required: [location] + UpdateUserDTO: + type: object + properties: + aiFeaturesEnabled: {type: boolean} + avatar: {type: string} + birthday: {type: string, format: date} + firstName: {type: string} + gender: {type: string} + lastName: {type: string} + linkedinUrl: {type: string} + nationality: {type: string} + phoneNumber: {type: string} + selectedLanguage: {type: string} + universityId: {type: string} + website: {type: string} UpdateUserNameDTO: type: object properties: diff --git a/src/main/webapp/app/generated/.openapi-generator/FILES b/src/main/webapp/app/generated/.openapi-generator/FILES index fb35e47b05..74e42d50ec 100644 --- a/src/main/webapp/app/generated/.openapi-generator/FILES +++ b/src/main/webapp/app/generated/.openapi-generator/FILES @@ -43,6 +43,8 @@ api/research-group-resource-api.ts api/research-group-resource-resources.ts api/school-resource-api.ts api/school-resource-resources.ts +api/user-admin-resource-api.ts +api/user-admin-resource-resources.ts api/user-data-export-resource-api.ts api/user-data-export-resource-resources.ts api/user-resource-api.ts @@ -51,6 +53,8 @@ model/accept-dto.ts model/add-interviewees-dto.ts model/add-members-to-research-group-dto.ts model/admin-export-task-dto.ts +model/admin-user-detail-dto.ts +model/admin-user-overview-dto.ts model/ai-feature-status-dto.ts model/applicant-dto.ts model/applicant-for-application-detail-dto.ts @@ -74,6 +78,7 @@ model/compliance-issue.ts model/conflict-data-dto.ts model/counts.ts model/create-slots-dto.ts +model/create-user-dto.ts model/created-job-dto.ts model/data-export-status-dto.ts model/department-creation-dto.ts @@ -93,6 +98,7 @@ model/extracted-certificate-data-dto.ts model/gender-bias-analysis-request.ts model/gender-bias-analysis-response.ts model/image-dto.ts +model/import-user-dto.ts model/internal-comment-dto.ts model/internal-comment-update-dto.ts model/interview-overview-dto.ts @@ -110,6 +116,7 @@ model/job-preview-request.ts model/keycloak-user-dto.ts model/login-request-dto.ts model/otp-complete-dto.ts +model/page-admin-user-overview-dto.ts model/page-application-overview-dto.ts model/page-created-job-dto.ts model/page-job-card-dto.ts @@ -148,6 +155,7 @@ model/update-assessment-dto.ts model/update-avatar-dto.ts model/update-password-dto.ts model/update-slot-location-dto.ts +model/update-user-dto.ts model/update-user-name-dto.ts model/user-booking-info-dto.ts model/user-data-export-exception-cause-stack-trace-inner.ts diff --git a/src/main/webapp/app/generated/api/user-admin-resource-api.ts b/src/main/webapp/app/generated/api/user-admin-resource-api.ts new file mode 100644 index 0000000000..50ae4fba81 --- /dev/null +++ b/src/main/webapp/app/generated/api/user-admin-resource-api.ts @@ -0,0 +1,122 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + +/** + * UserAdminResourceApi - API service + * @generated from OpenAPI specification + */ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AdminUserDetailDTO } from '../model/admin-user-detail-dto'; +import { CreateUserDTO } from '../model/create-user-dto'; +import { PageAdminUserOverviewDTO } from '../model/page-admin-user-overview-dto'; +import { ImportUserDTO } from '../model/import-user-dto'; +import { UpdateUserDTO } from '../model/update-user-dto'; + +@Injectable({ providedIn: 'root' }) +export class UserAdminResourceApi { + private readonly http = inject(HttpClient); + private readonly basePath = ''; + + /** + * + * + * @param createUserDTO + */ + createUser(createUserDTO: CreateUserDTO): Observable { + const url = `${this.basePath}/api/admin/users`; + return this.http.post(url, createUserDTO); + } + + /** + * + * + * @param userId + */ + deleteUser(userId: string): Observable { + const userIdPath = encodeURIComponent(String(userId)); + const url = `${this.basePath}/api/admin/users/${userIdPath}`; + return this.http.delete(url); + } + + /** + * + * + * @param pageSize + * @param pageNumber + * @param sortBy + * @param direction + * @param roles + * @param researchGroupIds + * @param searchQuery + */ + getAllUsers(pageSize?: number, pageNumber?: number, sortBy?: string, direction?: 'ASC' | 'DESC', roles?: Array<'APPLICANT' | 'PROFESSOR' | 'ADMIN' | 'EMPLOYEE'>, researchGroupIds?: Array, searchQuery?: string): Observable { + const queryParams = new URLSearchParams(); + if (pageSize !== undefined && pageSize !== null) { + queryParams.set('pageSize', String(pageSize)); + } + if (pageNumber !== undefined && pageNumber !== null) { + queryParams.set('pageNumber', String(pageNumber)); + } + if (sortBy !== undefined && sortBy !== null) { + queryParams.set('sortBy', String(sortBy)); + } + if (direction !== undefined && direction !== null) { + queryParams.set('direction', String(direction)); + } + if (roles !== undefined && roles !== null) { + roles.forEach(item => queryParams.append('roles', String(item))); + } + if (researchGroupIds !== undefined && researchGroupIds !== null) { + researchGroupIds.forEach(item => queryParams.append('researchGroupIds', String(item))); + } + if (searchQuery !== undefined && searchQuery !== null) { + queryParams.set('searchQuery', String(searchQuery)); + } + const queryString = queryParams.toString(); + const url = `${this.basePath}/api/admin/users${queryString ? `?${queryString}` : ''}`; + return this.http.get(url); + } + + /** + * + * + * @param userId + */ + getUser(userId: string): Observable { + const userIdPath = encodeURIComponent(String(userId)); + const url = `${this.basePath}/api/admin/users/${userIdPath}`; + return this.http.get(url); + } + + /** + * + * + * @param importUserDTO + */ + importUser(importUserDTO: ImportUserDTO): Observable { + const url = `${this.basePath}/api/admin/users/import`; + return this.http.post(url, importUserDTO); + } + + /** + * + * + * @param userId + * @param updateUserDTO + */ + updateUser(userId: string, updateUserDTO: UpdateUserDTO): Observable { + const userIdPath = encodeURIComponent(String(userId)); + const url = `${this.basePath}/api/admin/users/${userIdPath}`; + return this.http.put(url, updateUserDTO); + } + +} diff --git a/src/main/webapp/app/generated/api/user-admin-resource-resources.ts b/src/main/webapp/app/generated/api/user-admin-resource-resources.ts new file mode 100644 index 0000000000..59768f8443 --- /dev/null +++ b/src/main/webapp/app/generated/api/user-admin-resource-resources.ts @@ -0,0 +1,84 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + +/** + * UserAdminResourceApi Resources - Signal-based HTTP resources for GET operations + * Uses Angular's httpResource for reactive data fetching + * @generated from OpenAPI specification + */ +import { httpResource, HttpResourceRef } from '@angular/common/http'; +import { Signal } from '@angular/core'; +import { AdminUserDetailDTO } from '../model/admin-user-detail-dto'; +import { PageAdminUserOverviewDTO } from '../model/page-admin-user-overview-dto'; +const BASE_PATH = ''; + +/** + * Query parameters for getAllUsers + */ +export interface GetAllUsersParams { + pageSize?: number; + pageNumber?: number; + sortBy?: string; + direction?: 'ASC' | 'DESC'; + roles?: Array<'APPLICANT' | 'PROFESSOR' | 'ADMIN' | 'EMPLOYEE'>; + researchGroupIds?: Array; + searchQuery?: string; +} + +/** + * + * + * Creates a reactive HTTP resource that automatically refetches when signals change. + * @param params Optional signal containing query parameters + */ +export function getAllUsersResource(params?: Signal): HttpResourceRef { + return httpResource(() => { + const queryParams = params?.() ?? {}; + const searchParams = new URLSearchParams(); + if (queryParams.pageSize !== undefined && queryParams.pageSize !== null) { + searchParams.set('pageSize', String(queryParams.pageSize)); + } + if (queryParams.pageNumber !== undefined && queryParams.pageNumber !== null) { + searchParams.set('pageNumber', String(queryParams.pageNumber)); + } + if (queryParams.sortBy !== undefined && queryParams.sortBy !== null) { + searchParams.set('sortBy', String(queryParams.sortBy)); + } + if (queryParams.direction !== undefined && queryParams.direction !== null) { + searchParams.set('direction', String(queryParams.direction)); + } + if (queryParams.roles?.length) { + queryParams.roles.forEach(value => searchParams.append('roles', String(value))); + } + if (queryParams.researchGroupIds?.length) { + queryParams.researchGroupIds.forEach(value => searchParams.append('researchGroupIds', String(value))); + } + if (queryParams.searchQuery !== undefined && queryParams.searchQuery !== null) { + searchParams.set('searchQuery', String(queryParams.searchQuery)); + } + const query = searchParams.toString(); + return `${BASE_PATH}/api/admin/users${query ? `?${query}` : ''}`; + }); +} + +/** + * + * + * Creates a reactive HTTP resource that automatically refetches when signals change. + * @param userId + */ +export function getUserResource(userId: Signal | string): HttpResourceRef { + return httpResource(() => { + const userIdValue = typeof userId === 'function' ? userId() : userId; + const userIdPath = encodeURIComponent(String(userIdValue)); + return `${BASE_PATH}/api/admin/users/${userIdPath}`; + }); +} + diff --git a/src/main/webapp/app/generated/model/add-members-to-research-group-dto.ts b/src/main/webapp/app/generated/model/add-members-to-research-group-dto.ts index bbb348e7ba..976f66b56d 100644 --- a/src/main/webapp/app/generated/model/add-members-to-research-group-dto.ts +++ b/src/main/webapp/app/generated/model/add-members-to-research-group-dto.ts @@ -13,4 +13,17 @@ import type { KeycloakUserDTO } from './keycloak-user-dto'; export interface AddMembersToResearchGroupDTO { readonly keycloakUsers: Array; readonly researchGroupId?: string; + readonly role?: AddMembersToResearchGroupDTORoleEnum; } + +export type AddMembersToResearchGroupDTORoleEnum = 'APPLICANT' | 'PROFESSOR' | 'ADMIN' | 'EMPLOYEE'; + +export const AddMembersToResearchGroupDTORoleEnum = { + Applicant: 'APPLICANT' as const, + Professor: 'PROFESSOR' as const, + Admin: 'ADMIN' as const, + Employee: 'EMPLOYEE' as const, +} as const; + +export const AddMembersToResearchGroupDTORoleEnumValues = ['APPLICANT', 'PROFESSOR', 'ADMIN', 'EMPLOYEE'] as const; + diff --git a/src/main/webapp/app/generated/model/admin-user-detail-dto.ts b/src/main/webapp/app/generated/model/admin-user-detail-dto.ts new file mode 100644 index 0000000000..c30ce47924 --- /dev/null +++ b/src/main/webapp/app/generated/model/admin-user-detail-dto.ts @@ -0,0 +1,44 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + + +export interface AdminUserDetailDTO { + readonly aiFeaturesEnabled?: boolean; + readonly avatar?: string; + readonly birthday?: string; + readonly createdAt?: string; + readonly email?: string; + readonly firstName?: string; + readonly gender?: string; + readonly lastActivityAt?: string; + readonly lastName?: string; + readonly linkedinUrl?: string; + readonly nationality?: string; + readonly phoneNumber?: string; + readonly primaryRole?: AdminUserDetailDTOPrimaryRoleEnum; + readonly researchGroupId?: string; + readonly researchGroupName?: string; + readonly selectedLanguage?: string; + readonly universityId?: string; + readonly userId: string; + readonly website?: string; +} + +export type AdminUserDetailDTOPrimaryRoleEnum = 'APPLICANT' | 'PROFESSOR' | 'ADMIN' | 'EMPLOYEE'; + +export const AdminUserDetailDTOPrimaryRoleEnum = { + Applicant: 'APPLICANT' as const, + Professor: 'PROFESSOR' as const, + Admin: 'ADMIN' as const, + Employee: 'EMPLOYEE' as const, +} as const; + +export const AdminUserDetailDTOPrimaryRoleEnumValues = ['APPLICANT', 'PROFESSOR', 'ADMIN', 'EMPLOYEE'] as const; + diff --git a/src/main/webapp/app/generated/model/admin-user-overview-dto.ts b/src/main/webapp/app/generated/model/admin-user-overview-dto.ts new file mode 100644 index 0000000000..403d2dbbf6 --- /dev/null +++ b/src/main/webapp/app/generated/model/admin-user-overview-dto.ts @@ -0,0 +1,35 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + + +export interface AdminUserOverviewDTO { + readonly avatar?: string; + readonly email?: string; + readonly firstName?: string; + readonly lastActivityAt?: string; + readonly lastName?: string; + readonly primaryRole?: AdminUserOverviewDTOPrimaryRoleEnum; + readonly researchGroupId?: string; + readonly researchGroupName?: string; + readonly universityId?: string; + readonly userId: string; +} + +export type AdminUserOverviewDTOPrimaryRoleEnum = 'APPLICANT' | 'PROFESSOR' | 'ADMIN' | 'EMPLOYEE'; + +export const AdminUserOverviewDTOPrimaryRoleEnum = { + Applicant: 'APPLICANT' as const, + Professor: 'PROFESSOR' as const, + Admin: 'ADMIN' as const, + Employee: 'EMPLOYEE' as const, +} as const; + +export const AdminUserOverviewDTOPrimaryRoleEnumValues = ['APPLICANT', 'PROFESSOR', 'ADMIN', 'EMPLOYEE'] as const; + diff --git a/src/main/webapp/app/generated/model/create-user-dto.ts b/src/main/webapp/app/generated/model/create-user-dto.ts new file mode 100644 index 0000000000..09b4b7771f --- /dev/null +++ b/src/main/webapp/app/generated/model/create-user-dto.ts @@ -0,0 +1,25 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + + +export interface CreateUserDTO { + readonly birthday?: string; + readonly email: string; + readonly firstName: string; + readonly gender?: string; + readonly lastName: string; + readonly linkedinUrl?: string; + readonly nationality?: string; + readonly password: string; + readonly phoneNumber?: string; + readonly selectedLanguage?: string; + readonly universityId?: string; + readonly website?: string; +} diff --git a/src/main/webapp/app/generated/model/import-user-dto.ts b/src/main/webapp/app/generated/model/import-user-dto.ts new file mode 100644 index 0000000000..8503521cde --- /dev/null +++ b/src/main/webapp/app/generated/model/import-user-dto.ts @@ -0,0 +1,14 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + + +export interface ImportUserDTO { + readonly keycloakUserId: string; +} diff --git a/src/main/webapp/app/generated/model/page-admin-user-overview-dto.ts b/src/main/webapp/app/generated/model/page-admin-user-overview-dto.ts new file mode 100644 index 0000000000..4268fc1a47 --- /dev/null +++ b/src/main/webapp/app/generated/model/page-admin-user-overview-dto.ts @@ -0,0 +1,27 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + +import type { PageableObject } from './pageable-object'; +import type { AdminUserOverviewDTO } from './admin-user-overview-dto'; +import type { SortObject } from './sort-object'; + +export interface PageAdminUserOverviewDTO { + readonly content?: Array; + readonly empty?: boolean; + readonly first?: boolean; + readonly last?: boolean; + readonly number?: number; + readonly numberOfElements?: number; + readonly pageable?: PageableObject; + readonly size?: number; + readonly sort?: SortObject; + readonly totalElements?: number; + readonly totalPages?: number; +} diff --git a/src/main/webapp/app/generated/model/update-user-dto.ts b/src/main/webapp/app/generated/model/update-user-dto.ts new file mode 100644 index 0000000000..8ca8a09170 --- /dev/null +++ b/src/main/webapp/app/generated/model/update-user-dto.ts @@ -0,0 +1,25 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * API Version: v0 + * + * + * NOTE: This file is auto-generated. Do not edit manually. + */ + + +export interface UpdateUserDTO { + readonly aiFeaturesEnabled?: boolean; + readonly avatar?: string; + readonly birthday?: string; + readonly firstName?: string; + readonly gender?: string; + readonly lastName?: string; + readonly linkedinUrl?: string; + readonly nationality?: string; + readonly phoneNumber?: string; + readonly selectedLanguage?: string; + readonly universityId?: string; + readonly website?: string; +} From 76cfec58940eb0b53dfbeaf6fd3b52720e871a8a Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 18:40:12 +0200 Subject: [PATCH 10/28] General: add Manage Users admin page wiring, list, and form Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/webapp/app/app.routes.ts | 25 ++ .../manage-user-form.component.html | 255 +++++++++++ .../manage-user-form.component.ts | 398 ++++++++++++++++++ .../manage-users-page.component.html | 93 ++++ .../manage-users-page.component.ts | 322 ++++++++++++++ src/main/webapp/i18n/de/global.json | 5 + src/main/webapp/i18n/de/usermanagement.json | 90 ++++ src/main/webapp/i18n/en/global.json | 5 + src/main/webapp/i18n/en/usermanagement.json | 90 ++++ 9 files changed, 1283 insertions(+) create mode 100644 src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html create mode 100644 src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts create mode 100644 src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html create mode 100644 src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts create mode 100644 src/main/webapp/i18n/de/usermanagement.json create mode 100644 src/main/webapp/i18n/en/usermanagement.json diff --git a/src/main/webapp/app/app.routes.ts b/src/main/webapp/app/app.routes.ts index 2cf164be9d..914aa3bb1c 100644 --- a/src/main/webapp/app/app.routes.ts +++ b/src/main/webapp/app/app.routes.ts @@ -292,6 +292,31 @@ const routes: Routes = [ title: 'global.routes.researchGroup.info', }, + // ====================================================================================== + // Admin - Manage Users + // ====================================================================================== + { + path: 'manage-users', + canActivate: [UserRouteAccessService], + data: { authorities: [UserShortDTORolesEnum.Admin] }, + loadComponent: () => import('./usermanagement/manage-users/manage-users-page.component').then(m => m.ManageUsersPageComponent), + title: 'global.routes.user.manageUsers', + }, + { + path: 'manage-users/create', + canActivate: [UserRouteAccessService], + data: { authorities: [UserShortDTORolesEnum.Admin] }, + loadComponent: () => import('./usermanagement/manage-users/manage-user-form.component').then(m => m.ManageUserFormComponent), + title: 'global.routes.user.createUser', + }, + { + path: 'manage-users/:userId', + canActivate: [UserRouteAccessService], + data: { authorities: [UserShortDTORolesEnum.Admin] }, + loadComponent: () => import('./usermanagement/manage-users/manage-user-form.component').then(m => m.ManageUserFormComponent), + title: 'global.routes.user.userDetail', + }, + // ====================================================================================== // About Us // ====================================================================================== diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html new file mode 100644 index 0000000000..2566375149 --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html @@ -0,0 +1,255 @@ +

+ + +
+
+ +

+
+ @if (mode() === 'edit') { + + } +
+ +
+ @if (mode() === 'import') { + + + +
+
+ +
+
+
+ } @else { + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + @if (mode() === 'create') { +
+
+ +
+
+
+ } + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + @if (mode() === 'edit' && loadedUser(); as user) { + + + + +
+
+ + + +
{{ user.userId }}
+
+
+ + + +
{{ user.createdAt | localizedDate }}
+
+
+ +
+
+ + + +
{{ user.lastActivityAt | localizedDate }}
+
+
+ + + +
{{ user.aiFeaturesEnabled ? '✓' : '–' }}
+
+
+ } + } + +
+ +
+
+
diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts new file mode 100644 index 0000000000..d477c66e38 --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts @@ -0,0 +1,398 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { DividerModule } from 'primeng/divider'; +import { firstValueFrom } from 'rxjs'; +import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; +import { AdminUserDetailDTO } from 'app/generated/model/admin-user-detail-dto'; +import { CreateUserDTO } from 'app/generated/model/create-user-dto'; +import { ImportUserDTO } from 'app/generated/model/import-user-dto'; +import { UpdateUserDTO } from 'app/generated/model/update-user-dto'; +import { ToastService } from 'app/service/toast-service'; +import { ButtonComponent } from 'app/shared/components/atoms/button/button.component'; +import { ConfirmDialog } from 'app/shared/components/atoms/confirm-dialog/confirm-dialog'; +import { DatePickerComponent } from 'app/shared/components/atoms/datepicker/datepicker.component'; +import { SelectComponent, SelectOption } from 'app/shared/components/atoms/select/select.component'; +import { StringInputComponent } from 'app/shared/components/atoms/string-input/string-input.component'; +import { selectGender } from 'app/shared/constants/genders'; +import { TranslateDirective } from 'app/shared/language'; +import { selectNationality } from 'app/shared/language/nationalities'; +import LocalizedDatePipe from 'app/shared/pipes/localized-date.pipe'; + +// nosemgrep: javascript.security.hard-coded-password +const TRANSLATION_KEY = 'manageUsersPage'; + +type FormMode = 'create' | 'edit' | 'import'; + +const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/; + +/** + * Admin form for creating, importing, editing, and deleting a user. + * The mode is derived from the route + query params: + * - `/manage-users/create` becomes create + * - `/manage-users/create?mode=import` becomes import + * - `/manage-users/:userId` becomes edit + */ +@Component({ + selector: 'jhi-manage-user-form', + imports: [ + ButtonComponent, + ConfirmDialog, + DatePickerComponent, + DividerModule, + LocalizedDatePipe, + ReactiveFormsModule, + RouterModule, + SelectComponent, + StringInputComponent, + TranslateDirective, + TranslateModule, + ], + templateUrl: './manage-user-form.component.html', +}) +export class ManageUserFormComponent { + // ------- Signals: mode + state ------- + readonly mode = signal('create'); + readonly loadedUser = signal(undefined); + readonly isSubmitting = signal(false); + readonly showDeleteDialog = signal(false); + + // ------- Select options ------- + readonly genderOptions: SelectOption[] = selectGender; + readonly nationalityOptionsBase: SelectOption[] = selectNationality; + readonly languageOptions: SelectOption[] = [ + { value: 'en', name: 'languages.en' }, + { value: 'de', name: 'languages.de' }, + ]; + + // ------- Selected select-option signals ------- + readonly selectedGender = signal(undefined); + readonly selectedNationality = signal(undefined); + readonly selectedLanguage = signal(undefined); + readonly birthday = signal(''); + + // ------- Date bounds ------- + readonly minBirthday = new Date(1900, 0, 1); + readonly maxBirthday = new Date(); + readonly defaultBirthday = new Date(2000, 0, 1); + + // ------- Reactive form (public for template) ------- + readonly form = inject(FormBuilder).nonNullable.group({ + firstName: ['', [Validators.required]], + lastName: ['', [Validators.required]], + email: ['', [Validators.required, Validators.email]], + password: [''], + keycloakUserId: [''], + universityId: [''], + phoneNumber: [''], + website: [''], + linkedinUrl: [''], + }); + + // ------- Computed view-model fields ------- + readonly title = computed(() => { + switch (this.mode()) { + case 'edit': + return `${TRANSLATION_KEY}.form.editTitle`; + case 'import': + return `${TRANSLATION_KEY}.form.importTitle`; + default: + return `${TRANSLATION_KEY}.form.createTitle`; + } + }); + + readonly submitLabel = computed(() => { + switch (this.mode()) { + case 'edit': + return `${TRANSLATION_KEY}.form.buttons.save`; + case 'import': + return `${TRANSLATION_KEY}.form.buttons.importUser`; + default: + return `${TRANSLATION_KEY}.form.buttons.createUser`; + } + }); + + /** Translation key for the page block; exposed for use in the template. */ + readonly translationKey = TRANSLATION_KEY; + + // ------- Injected services ------- + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + private readonly translate = inject(TranslateService); + private readonly userAdminApi = inject(UserAdminResourceApi); + + // Effect that sets mode from route params + loads user when in edit. + private readonly modeEffect = effect(() => { + const params = this.route.snapshot.paramMap; + const queryParams = this.route.snapshot.queryParamMap; + const userId = params.get('userId'); + const queryMode = queryParams.get('mode'); + + let nextMode: FormMode = 'create'; + if (userId !== null && userId !== '') { + nextMode = 'edit'; + } else if (queryMode === 'import') { + nextMode = 'import'; + } + this.mode.set(nextMode); + this.applyValidatorsForMode(nextMode); + + if (nextMode === 'edit' && userId !== null && userId !== '') { + void this.loadUser(userId); + } + }); + + /** + * Submit the form for the current mode. Routes to the matching API call. + */ + async onSubmit(): Promise { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.isSubmitting.set(true); + try { + switch (this.mode()) { + case 'create': + await this.submitCreate(); + break; + case 'import': + await this.submitImport(); + break; + case 'edit': + await this.submitUpdate(); + break; + } + } finally { + this.isSubmitting.set(false); + } + } + + /** + * Open the delete confirmation dialog for the loaded user (edit mode only). + */ + onRequestDelete(): void { + if (this.mode() !== 'edit') { + return; + } + this.showDeleteDialog.set(true); + } + + /** + * Confirm deletion handler bound to the confirm-dialog. Deletes the loaded + * user from Keycloak and anonymises their database references. + */ + async onConfirmDelete(): Promise { + const userId = this.loadedUser()?.userId; + if (userId === undefined || userId === '') { + return; + } + try { + await firstValueFrom(this.userAdminApi.deleteUser(userId)); + this.toastService.showSuccess({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.deleteSuccess`), + }); + void this.router.navigate(['/manage-users']); + } catch (error) { + this.toastService.showError({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.deleteFailed`, { + detail: this.extractErrorDetail(error), + }), + }); + } + } + + /** Navigate back to the list page. */ + onBack(): void { + void this.router.navigate(['/manage-users']); + } + + updateGender(option: SelectOption | undefined): void { + this.selectedGender.set(option); + } + + updateNationality(option: SelectOption | undefined): void { + this.selectedNationality.set(option); + } + + updateLanguage(option: SelectOption | undefined): void { + this.selectedLanguage.set(option); + } + + updateBirthday(value: string | undefined): void { + this.birthday.set(value ?? ''); + } + + /** + * Apply mode-specific validators. Reactive Forms only — controls always exist. + * + * @param mode the current form mode + */ + private applyValidatorsForMode(mode: FormMode): void { + const passwordControl = this.form.controls.password; + const keycloakControl = this.form.controls.keycloakUserId; + const firstNameControl = this.form.controls.firstName; + const lastNameControl = this.form.controls.lastName; + const emailControl = this.form.controls.email; + + if (mode === 'create') { + passwordControl.setValidators([Validators.required, Validators.pattern(PASSWORD_PATTERN)]); + keycloakControl.clearValidators(); + firstNameControl.setValidators([Validators.required]); + lastNameControl.setValidators([Validators.required]); + emailControl.setValidators([Validators.required, Validators.email]); + } else if (mode === 'import') { + passwordControl.clearValidators(); + keycloakControl.setValidators([Validators.required]); + firstNameControl.clearValidators(); + lastNameControl.clearValidators(); + emailControl.clearValidators(); + } else { + // edit + passwordControl.clearValidators(); + keycloakControl.clearValidators(); + firstNameControl.setValidators([Validators.required]); + lastNameControl.setValidators([Validators.required]); + emailControl.setValidators([Validators.required, Validators.email]); + } + + passwordControl.updateValueAndValidity({ emitEvent: false }); + keycloakControl.updateValueAndValidity({ emitEvent: false }); + firstNameControl.updateValueAndValidity({ emitEvent: false }); + lastNameControl.updateValueAndValidity({ emitEvent: false }); + emailControl.updateValueAndValidity({ emitEvent: false }); + } + + private async loadUser(userId: string): Promise { + try { + const user = await firstValueFrom(this.userAdminApi.getUser(userId)); + this.loadedUser.set(user); + this.form.patchValue({ + firstName: user.firstName ?? '', + lastName: user.lastName ?? '', + email: user.email ?? '', + universityId: user.universityId ?? '', + phoneNumber: user.phoneNumber ?? '', + website: user.website ?? '', + linkedinUrl: user.linkedinUrl ?? '', + }); + this.form.controls.email.disable({ emitEvent: false }); + this.selectedGender.set(this.findOption(this.genderOptions, user.gender)); + this.selectedNationality.set(this.findOption(this.nationalityOptionsBase, user.nationality)); + this.selectedLanguage.set(this.findOption(this.languageOptions, user.selectedLanguage)); + this.birthday.set(user.birthday ?? ''); + } catch { + this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.loadUser`); + } + } + + private async submitCreate(): Promise { + const value = this.form.getRawValue(); + const dto: CreateUserDTO = { + firstName: value.firstName, + lastName: value.lastName, + email: value.email, + password: value.password, + universityId: this.emptyToUndefined(value.universityId), + phoneNumber: this.emptyToUndefined(value.phoneNumber), + website: this.emptyToUndefined(value.website), + linkedinUrl: this.emptyToUndefined(value.linkedinUrl), + gender: this.selectOptionValue(this.selectedGender()), + nationality: this.selectOptionValue(this.selectedNationality()), + selectedLanguage: this.selectOptionValue(this.selectedLanguage()), + birthday: this.emptyToUndefined(this.birthday()), + }; + + try { + const created = await firstValueFrom(this.userAdminApi.createUser(dto)); + this.toastService.showSuccess({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.createSuccess`), + }); + void this.router.navigate(['/manage-users', created.userId]); + } catch (error) { + this.toastService.showError({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.createFailed`, { + detail: this.extractErrorDetail(error), + }), + }); + } + } + + private async submitImport(): Promise { + const value = this.form.getRawValue(); + const dto: ImportUserDTO = { keycloakUserId: value.keycloakUserId }; + try { + const imported = await firstValueFrom(this.userAdminApi.importUser(dto)); + this.toastService.showSuccess({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.importSuccess`), + }); + void this.router.navigate(['/manage-users', imported.userId]); + } catch (error) { + this.toastService.showError({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.importFailed`, { + detail: this.extractErrorDetail(error), + }), + }); + } + } + + private async submitUpdate(): Promise { + const userId = this.loadedUser()?.userId; + if (userId === undefined || userId === '') { + return; + } + const value = this.form.getRawValue(); + const dto: UpdateUserDTO = { + firstName: value.firstName, + lastName: value.lastName, + universityId: this.emptyToUndefined(value.universityId), + phoneNumber: this.emptyToUndefined(value.phoneNumber), + website: this.emptyToUndefined(value.website), + linkedinUrl: this.emptyToUndefined(value.linkedinUrl), + gender: this.selectOptionValue(this.selectedGender()), + nationality: this.selectOptionValue(this.selectedNationality()), + selectedLanguage: this.selectOptionValue(this.selectedLanguage()), + birthday: this.emptyToUndefined(this.birthday()), + }; + + try { + const updated = await firstValueFrom(this.userAdminApi.updateUser(userId, dto)); + this.loadedUser.set(updated); + this.toastService.showSuccess({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.updateSuccess`), + }); + } catch (error) { + this.toastService.showError({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.updateFailed`, { + detail: this.extractErrorDetail(error), + }), + }); + } + } + + private findOption(options: SelectOption[], value: string | undefined): SelectOption | undefined { + if (value === undefined || value === '') return undefined; + return options.find(option => option.value === value); + } + + private selectOptionValue(option: SelectOption | undefined): string | undefined { + if (option === undefined) return undefined; + return typeof option.value === 'string' ? option.value : String(option.value); + } + + private emptyToUndefined(value: string | undefined): string | undefined { + return value !== undefined && value !== '' ? value : undefined; + } + + private extractErrorDetail(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return ''; + } +} diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html new file mode 100644 index 0000000000..f462eb930a --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html @@ -0,0 +1,93 @@ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + {{ fullName(row) }} +
+
+ + + @if (row.primaryRole) { + + } + + + + {{ row.lastActivityAt | localizedDate }} + + + +
+ + +
+
+
+
diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts new file mode 100644 index 0000000000..e081e083ca --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts @@ -0,0 +1,322 @@ +import { Component, TemplateRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { TableLazyLoadEvent } from 'primeng/table'; +import { firstValueFrom } from 'rxjs'; +import { AccountService } from 'app/core/auth/account.service'; +import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; +import { ResearchGroupResourceApi } from 'app/generated/api/research-group-resource-api'; +import { + AdminUserOverviewDTO, + AdminUserOverviewDTOPrimaryRoleEnum, + AdminUserOverviewDTOPrimaryRoleEnumValues, +} from 'app/generated/model/admin-user-overview-dto'; +import { ResearchGroupAdminDTO } from 'app/generated/model/research-group-admin-dto'; +import { ToastService } from 'app/service/toast-service'; +import { ButtonComponent } from 'app/shared/components/atoms/button/button.component'; +import { ConfirmDialog } from 'app/shared/components/atoms/confirm-dialog/confirm-dialog'; +import { Filter, FilterChange } from 'app/shared/components/atoms/filter-multiselect/filter-multiselect'; +import { Sort, SortDirection, SortOption } from 'app/shared/components/atoms/sorting/sorting'; +import { UserAvatarComponent } from 'app/shared/components/atoms/user-avatar/user-avatar.component'; +import { SearchFilterSortBar } from 'app/shared/components/molecules/search-filter-sort-bar/search-filter-sort-bar'; +import { DynamicTableColumn, DynamicTableComponent } from 'app/shared/components/organisms/dynamic-table/dynamic-table.component'; +import { TranslateDirective } from 'app/shared/language'; +import LocalizedDatePipe from 'app/shared/pipes/localized-date.pipe'; + +// nosemgrep: javascript.security.hard-coded-password +const TRANSLATION_KEY = 'manageUsersPage'; + +interface ResearchGroupOption { + id: string; + name: string; +} + +/** + * Admin-only page that lists all users (with role + research-group filters, + * search, sort and pagination) and lets admins create, import, view, or + * delete users. + */ +@Component({ + selector: 'jhi-manage-users-page', + imports: [ + ButtonComponent, + ConfirmDialog, + DynamicTableComponent, + LocalizedDatePipe, + RouterModule, + SearchFilterSortBar, + TranslateDirective, + TranslateModule, + UserAvatarComponent, + ], + templateUrl: './manage-users-page.component.html', +}) +export class ManageUsersPageComponent { + // ------- Signals: data + paging ------- + users = signal([]); + totalRecords = signal(0); + page = signal(0); + pageSize = signal(10); + loading = signal(true); + searchQuery = signal(''); + + sortBy = signal('lastActivityAt'); + sortDirection = signal('DESC'); + + // ------- Filter state ------- + readonly availableRoleOptions: AdminUserOverviewDTOPrimaryRoleEnum[] = [...AdminUserOverviewDTOPrimaryRoleEnumValues]; + + /** Translation key per role enum, used by the filter-multiselect (translated). */ + readonly roleLabelMap: Record = { + APPLICANT: `${TRANSLATION_KEY}.roles.APPLICANT`, + PROFESSOR: `${TRANSLATION_KEY}.roles.PROFESSOR`, + ADMIN: `${TRANSLATION_KEY}.roles.ADMIN`, + EMPLOYEE: `${TRANSLATION_KEY}.roles.EMPLOYEE`, + }; + + readonly availableRoleLabels: string[] = this.availableRoleOptions.map(role => this.roleLabelMap[role]); + + selectedRoleFilters = signal([]); + selectedResearchGroupFilters = signal([]); + + researchGroupOptions = signal([]); + readonly researchGroupNameOptions = computed(() => this.researchGroupOptions().map(option => option.name)); + + // ------- Templates ------- + readonly userTemplate = viewChild.required>('userTemplate'); + readonly roleTemplate = viewChild.required>('roleTemplate'); + readonly lastActivityTemplate = viewChild.required>('lastActivityTemplate'); + readonly actionTemplate = viewChild.required>('actionTemplate'); + + // ------- Delete dialog state ------- + showDeleteDialog = signal(false); + currentUserToDelete = signal(undefined); + + // ------- Computed configs ------- + readonly columns = computed(() => { + const userTpl = this.userTemplate(); + const roleTpl = this.roleTemplate(); + const lastActivityTpl = this.lastActivityTemplate(); + const actionTpl = this.actionTemplate(); + + return [ + { field: 'fullName', header: `${TRANSLATION_KEY}.tableColumn.user`, width: '20rem', template: userTpl }, + { field: 'email', header: `${TRANSLATION_KEY}.tableColumn.email`, width: '18rem' }, + { field: 'universityId', header: `${TRANSLATION_KEY}.tableColumn.universityId`, width: '10rem' }, + { field: 'primaryRole', header: `${TRANSLATION_KEY}.tableColumn.role`, width: '8rem', template: roleTpl }, + { field: 'researchGroupName', header: `${TRANSLATION_KEY}.tableColumn.researchGroup`, width: '14rem' }, + { field: 'lastActivityAt', header: `${TRANSLATION_KEY}.tableColumn.lastActivity`, width: '10rem', template: lastActivityTpl }, + { field: 'actions', header: '', width: '6rem', template: actionTpl }, + ]; + }); + + readonly filters = computed(() => [ + { + filterId: 'role', + filterLabel: `${TRANSLATION_KEY}.filters.roleLabel`, + filterSearchPlaceholder: `${TRANSLATION_KEY}.searchFilterSortBar.filterOptions.roleSearchPlaceholder`, + filterOptions: this.availableRoleLabels, + shouldTranslateOptions: true, + }, + { + filterId: 'researchGroup', + filterLabel: `${TRANSLATION_KEY}.filters.researchGroupLabel`, + filterSearchPlaceholder: `${TRANSLATION_KEY}.searchFilterSortBar.filterOptions.researchGroupSearchPlaceholder`, + filterOptions: this.researchGroupNameOptions(), + shouldTranslateOptions: false, + }, + ]); + + readonly sortableFields: SortOption[] = [ + { displayName: `${TRANSLATION_KEY}.tableColumn.lastActivity`, fieldName: 'lastActivityAt', type: 'NUMBER' }, + { displayName: `${TRANSLATION_KEY}.tableColumn.user`, fieldName: 'lastName', type: 'TEXT' }, + { displayName: `${TRANSLATION_KEY}.tableColumn.email`, fieldName: 'email', type: 'TEXT' }, + ]; + + readonly currentUserId = computed(() => this.accountService.loadedUser()?.id ?? ''); + + /** Translation key for the page block; exposed for use in the template. */ + readonly translationKey = TRANSLATION_KEY; + + // ------- Injected services ------- + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + private readonly translate = inject(TranslateService); + private readonly accountService = inject(AccountService); + private readonly userAdminApi = inject(UserAdminResourceApi); + private readonly researchGroupApi = inject(ResearchGroupResourceApi); + + constructor() { + void this.loadResearchGroups(); + } + + /** + * Compute a display name for the user-template cell. + * + * @param row the AdminUserOverviewDTO row + * @returns the trimmed `firstName lastName`, or empty string when both are blank + */ + fullName(row: AdminUserOverviewDTO): string { + return `${row.firstName ?? ''} ${row.lastName ?? ''}`.trim(); + } + + /** + * Translation key for a row's role. + * + * @param row the AdminUserOverviewDTO row + * @returns a translation key under `manageUsersPage.roles`, or empty when unset + */ + roleLabelKey(row: AdminUserOverviewDTO): string { + const role = row.primaryRole; + return role ? this.roleLabelMap[role] : ''; + } + + async loadPage(event: TableLazyLoadEvent): Promise { + const rows = event.rows ?? this.pageSize(); + const first = event.first ?? 0; + this.pageSize.set(rows); + this.page.set(Math.floor(first / rows)); + await this.loadUsers(); + } + + onSearchEmit(searchQuery: string): void { + if (searchQuery !== this.searchQuery()) { + this.searchQuery.set(searchQuery); + this.page.set(0); + void this.loadUsers(); + } + } + + onFilterEmit(filterChange: FilterChange): void { + if (filterChange.filterId === 'role') { + const selectedRoles = this.mapRoleLabelsToEnums(filterChange.selectedValues); + this.selectedRoleFilters.set(selectedRoles); + } else if (filterChange.filterId === 'researchGroup') { + const selectedIds = this.mapResearchGroupNamesToIds(filterChange.selectedValues); + this.selectedResearchGroupFilters.set(selectedIds); + } + this.page.set(0); + void this.loadUsers(); + } + + onSortEmit(event: Sort): void { + this.sortBy.set(event.field); + this.sortDirection.set(event.direction); + this.page.set(0); + void this.loadUsers(); + } + + onCreateUser(): void { + void this.router.navigate(['/manage-users/create']); + } + + onImport(): void { + void this.router.navigate(['/manage-users/create'], { queryParams: { mode: 'import' } }); + } + + onViewUser(userId: string | undefined): void { + if (userId !== undefined && userId !== '') { + void this.router.navigate(['/manage-users', userId]); + } + } + + onRequestDelete(row: AdminUserOverviewDTO): void { + if (row.userId === this.currentUserId()) { + this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.selfDelete`); + return; + } + this.currentUserToDelete.set(row); + this.showDeleteDialog.set(true); + } + + async onConfirmDelete(): Promise { + const target = this.currentUserToDelete(); + const userId = target?.userId; + if (target === undefined || userId === undefined || userId === '') { + return; + } + try { + await firstValueFrom(this.userAdminApi.deleteUser(userId)); + this.toastService.showSuccess({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.deleteSuccess`), + }); + await this.loadUsers(); + } catch (error) { + this.toastService.showError({ + summary: this.translate.instant(`${TRANSLATION_KEY}.toastMessages.deleteFailed`, { + detail: this.extractErrorDetail(error), + }), + }); + } finally { + this.currentUserToDelete.set(undefined); + } + } + + isSelf(row: AdminUserOverviewDTO): boolean { + return row.userId === this.currentUserId(); + } + + private async loadUsers(): Promise { + this.loading.set(true); + try { + const roles = this.selectedRoleFilters(); + const researchGroupIds = this.selectedResearchGroupFilters(); + const page = await firstValueFrom( + this.userAdminApi.getAllUsers( + this.pageSize(), + this.page(), + this.sortBy(), + this.sortDirection(), + roles.length > 0 ? roles : undefined, + researchGroupIds.length > 0 ? researchGroupIds : undefined, + this.searchQuery() || undefined, + ), + ); + this.users.set(page.content ?? []); + this.totalRecords.set(page.totalElements ?? 0); + } catch { + this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.loadUsers`); + } finally { + this.loading.set(false); + } + } + + private async loadResearchGroups(): Promise { + try { + const page = await firstValueFrom(this.researchGroupApi.getResearchGroupsForAdmin(1000, 0)); + const options: ResearchGroupOption[] = (page.content ?? []) + .filter( + (group: ResearchGroupAdminDTO): group is ResearchGroupAdminDTO & { id: string; researchGroup: string } => + Boolean(group.id) && Boolean(group.researchGroup), + ) + .map(group => ({ id: group.id, name: group.researchGroup })) + .sort((a, b) => a.name.localeCompare(b.name)); + this.researchGroupOptions.set(options); + } catch { + this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.loadFilters`); + } + } + + private mapRoleLabelsToEnums(labels: string[]): AdminUserOverviewDTOPrimaryRoleEnum[] { + const labelToRole = new Map(); + for (const role of this.availableRoleOptions) { + labelToRole.set(this.roleLabelMap[role], role); + } + return labels.map(label => labelToRole.get(label)).filter((role): role is AdminUserOverviewDTOPrimaryRoleEnum => role !== undefined); + } + + private mapResearchGroupNamesToIds(names: string[]): string[] { + const nameToId = new Map(this.researchGroupOptions().map(option => [option.name, option.id])); + return names.map(name => nameToId.get(name)).filter((id): id is string => id !== undefined); + } + + private extractErrorDetail(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return ''; + } +} diff --git a/src/main/webapp/i18n/de/global.json b/src/main/webapp/i18n/de/global.json index f8c6ad6dbc..4f6e13e187 100644 --- a/src/main/webapp/i18n/de/global.json +++ b/src/main/webapp/i18n/de/global.json @@ -43,6 +43,11 @@ "exports": "TUMApply - Admin-Exporte", "systemSettings": "TUMApply - Systemeinstellungen" }, + "user": { + "manageUsers": "TUMApply - Nutzende verwalten", + "createUser": "TUMApply - Nutzende anlegen", + "userDetail": "TUMApply - Nutzendendetails" + }, "about-us": "TUMApply - Über uns", "interview": { "overview": "TUMApply - Interviewübersicht", diff --git a/src/main/webapp/i18n/de/usermanagement.json b/src/main/webapp/i18n/de/usermanagement.json new file mode 100644 index 0000000000..a1ade43f25 --- /dev/null +++ b/src/main/webapp/i18n/de/usermanagement.json @@ -0,0 +1,90 @@ +{ + "manageUsersPage": { + "header": { + "title": "Nutzende verwalten", + "createButton": "Nutzende anlegen", + "importButton": "Aus Keycloak importieren" + }, + "searchFilterSortBar": { + "searchText": "Suche nach Name, E-Mail oder Universitäts-ID", + "filterOptions": { + "roleSearchPlaceholder": "Rollen suchen", + "researchGroupSearchPlaceholder": "Forschungsgruppen suchen" + } + }, + "tableColumn": { + "user": "Nutzende", + "email": "E-Mail", + "universityId": "Universitäts-ID", + "role": "Rolle", + "researchGroup": "Forschungsgruppe", + "lastActivity": "Letzte Aktivität" + }, + "filters": { + "roleLabel": "Rolle", + "researchGroupLabel": "Forschungsgruppe" + }, + "roles": { + "APPLICANT": "Bewerbende", + "PROFESSOR": "Professoren", + "ADMIN": "Admin", + "EMPLOYEE": "Mitarbeitende" + }, + "form": { + "createTitle": "Nutzende anlegen", + "editTitle": "Nutzendendetails", + "importTitle": "Nutzende aus Keycloak importieren", + "identitySection": "Identität", + "profileSection": "Profil", + "statusSection": "Status", + "fields": { + "firstName": "Vorname", + "lastName": "Nachname", + "email": "E-Mail", + "password": "Passwort", + "universityId": "Universitäts-ID", + "userId": "Nutzenden-UUID", + "keycloakUserId": "Keycloak-Nutzenden-UUID", + "phoneNumber": "Telefonnummer", + "gender": "Geschlecht", + "nationality": "Nationalität", + "birthday": "Geburtsdatum", + "website": "Website", + "linkedinUrl": "LinkedIn", + "selectedLanguage": "Sprache", + "avatar": "Avatar-URL", + "createdAt": "Erstellt", + "lastActivityAt": "Letzte Aktivität", + "aiFeaturesEnabled": "KI-Funktionen aktiviert" + }, + "passwordRequirements": "Mindestens 8 Zeichen mit Groß- und Kleinbuchstaben sowie einer Ziffer.", + "buttons": { + "createUser": "Nutzende anlegen", + "importUser": "Importieren", + "save": "Speichern", + "delete": "Nutzende löschen", + "back": "Zurück" + } + }, + "confirmDialog": { + "deleteHeader": "Nutzende löschen", + "deleteMessage": "Damit werden die Nutzenden aus Keycloak gelöscht und ihre Verweise in der Datenbank anonymisiert. Bist du sicher?" + }, + "errors": { + "loadUsers": { "summary": "Nutzende konnten nicht geladen werden", "detail": "" }, + "loadUser": { "summary": "Nutzende konnten nicht geladen werden", "detail": "" }, + "loadFilters": { "summary": "Filteroptionen konnten nicht geladen werden", "detail": "" }, + "selfDelete": { "summary": "Du kannst dein eigenes Konto nicht löschen.", "detail": "" } + }, + "toastMessages": { + "createSuccess": "Nutzende angelegt.", + "createFailed": "Nutzende konnten nicht angelegt werden: {{ detail }}", + "updateSuccess": "Nutzende aktualisiert.", + "updateFailed": "Nutzende konnten nicht aktualisiert werden: {{ detail }}", + "deleteSuccess": "Nutzende gelöscht.", + "deleteFailed": "Nutzende konnten nicht gelöscht werden: {{ detail }}", + "importSuccess": "Nutzende aus Keycloak importiert.", + "importFailed": "Nutzende konnten nicht importiert werden: {{ detail }}" + } + } +} diff --git a/src/main/webapp/i18n/en/global.json b/src/main/webapp/i18n/en/global.json index 11e1f4b899..9b6475a3a9 100644 --- a/src/main/webapp/i18n/en/global.json +++ b/src/main/webapp/i18n/en/global.json @@ -43,6 +43,11 @@ "exports": "TUMApply - Admin Exports", "systemSettings": "TUMApply - System Settings" }, + "user": { + "manageUsers": "TUMApply - Manage Users", + "createUser": "TUMApply - Create User", + "userDetail": "TUMApply - User Detail" + }, "about-us": "TUMApply - About Us", "interview": { "overview": "TUMApply - Interview Overview", diff --git a/src/main/webapp/i18n/en/usermanagement.json b/src/main/webapp/i18n/en/usermanagement.json new file mode 100644 index 0000000000..40e2ccc257 --- /dev/null +++ b/src/main/webapp/i18n/en/usermanagement.json @@ -0,0 +1,90 @@ +{ + "manageUsersPage": { + "header": { + "title": "Manage Users", + "createButton": "Create User", + "importButton": "Import from Keycloak" + }, + "searchFilterSortBar": { + "searchText": "Search by name, email, or university id", + "filterOptions": { + "roleSearchPlaceholder": "Search roles", + "researchGroupSearchPlaceholder": "Search research groups" + } + }, + "tableColumn": { + "user": "User", + "email": "Email", + "universityId": "University ID", + "role": "Role", + "researchGroup": "Research Group", + "lastActivity": "Last Activity" + }, + "filters": { + "roleLabel": "Role", + "researchGroupLabel": "Research Group" + }, + "roles": { + "APPLICANT": "Applicant", + "PROFESSOR": "Professor", + "ADMIN": "Admin", + "EMPLOYEE": "Employee" + }, + "form": { + "createTitle": "Create User", + "editTitle": "User Detail", + "importTitle": "Import User from Keycloak", + "identitySection": "Identity", + "profileSection": "Profile", + "statusSection": "Status", + "fields": { + "firstName": "First Name", + "lastName": "Last Name", + "email": "Email", + "password": "Password", + "universityId": "University ID", + "userId": "User UUID", + "keycloakUserId": "Keycloak User UUID", + "phoneNumber": "Phone Number", + "gender": "Gender", + "nationality": "Nationality", + "birthday": "Date of Birth", + "website": "Website", + "linkedinUrl": "LinkedIn", + "selectedLanguage": "Language", + "avatar": "Avatar URL", + "createdAt": "Created", + "lastActivityAt": "Last Activity", + "aiFeaturesEnabled": "AI Features Enabled" + }, + "passwordRequirements": "At least 8 characters with uppercase, lowercase, and a digit.", + "buttons": { + "createUser": "Create User", + "importUser": "Import", + "save": "Save", + "delete": "Delete User", + "back": "Back" + } + }, + "confirmDialog": { + "deleteHeader": "Delete User", + "deleteMessage": "This will delete the user from Keycloak and anonymise their references in the database. Are you sure?" + }, + "errors": { + "loadUsers": { "summary": "Could not load users", "detail": "" }, + "loadUser": { "summary": "Could not load user", "detail": "" }, + "loadFilters": { "summary": "Could not load filter options", "detail": "" }, + "selfDelete": { "summary": "You cannot delete your own account.", "detail": "" } + }, + "toastMessages": { + "createSuccess": "User created.", + "createFailed": "Could not create user: {{ detail }}", + "updateSuccess": "User updated.", + "updateFailed": "Could not update user: {{ detail }}", + "deleteSuccess": "User deleted.", + "deleteFailed": "Could not delete user: {{ detail }}", + "importSuccess": "User imported from Keycloak.", + "importFailed": "Could not import user: {{ detail }}" + } + } +} From 65f9876636d9bc60cf76a2beefbe52c6bdbe4096 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 18:45:28 +0200 Subject: [PATCH 11/28] General: add role picker (PROFESSOR or EMPLOYEE) to research-group add-members Co-Authored-By: Claude Opus 4.7 (1M context) --- .../research-group-add-members.component.html | 15 ++++++++++ .../research-group-add-members.component.ts | 28 ++++++++++++++++++- src/main/webapp/i18n/de/research-group.json | 1 + src/main/webapp/i18n/en/research-group.json | 1 + ...search-group-add-members.component.spec.ts | 23 +++++++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html index 020a5b1c7c..7af8a80400 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html @@ -3,6 +3,21 @@
+ + +
+ +
+
([]); selectedUserCount = computed(() => this.selectedUsers().size); + selectedRole = signal(AddMembersToResearchGroupDTORoleEnum.Employee); + readonly roleOptions: SelectOption[] = [ + { value: AddMembersToResearchGroupDTORoleEnum.Employee, name: 'manageUsersPage.roles.EMPLOYEE' }, + { value: AddMembersToResearchGroupDTORoleEnum.Professor, name: 'manageUsersPage.roles.PROFESSOR' }, + ]; + selectedRoleOption = computed(() => this.roleOptions.find(option => option.value === this.selectedRole())); + userApi = inject(UserResourceApi); researchGroupApi = inject(ResearchGroupResourceApi); toastService = inject(ToastService); @@ -174,6 +184,18 @@ export class ResearchGroupAddMembersComponent { this.dialogRef.close(); } + /** + * Updates the selected role used when posting the add-members request. + * + * @param option - the option emitted by the role picker; ignored when not a known role value + */ + onRoleChange(option: SelectOption): void { + const value = option.value; + if (value === AddMembersToResearchGroupDTORoleEnum.Professor || value === AddMembersToResearchGroupDTORoleEnum.Employee) { + this.selectedRole.set(value); + } + } + async onAddMembers(): Promise { if (this.selectedUsers().size === 0) { return; @@ -182,7 +204,11 @@ export class ResearchGroupAddMembersComponent { try { const researchGroupId = this.researchGroupId(); - const data = { keycloakUsers: Array.from(this.selectedUsers().values()), researchGroupId }; + const data = { + keycloakUsers: Array.from(this.selectedUsers().values()), + researchGroupId, + role: this.selectedRole(), + }; await lastValueFrom(this.researchGroupApi.addMembersToResearchGroup(data)); this.toastService.showSuccessKey(`${I18N_BASE}.toastMessages.addMembersSuccess`); this.dialogRef.close(true); diff --git a/src/main/webapp/i18n/de/research-group.json b/src/main/webapp/i18n/de/research-group.json index caecbdf1fa..c87372a140 100644 --- a/src/main/webapp/i18n/de/research-group.json +++ b/src/main/webapp/i18n/de/research-group.json @@ -115,6 +115,7 @@ "manageMembers": "Mitglieder verwalten", "addMembers": "Mitglieder hinzufügen", "addMembersButton": "Mitglieder hinzufügen", + "roleLabel": "Rolle", "searchPlaceholder": "Suche nach einem Mitglied mit Namen oder E-Mail...", "singleEntity": "Benutzer:in", "multipleEntities": "Benutzer:innen", diff --git a/src/main/webapp/i18n/en/research-group.json b/src/main/webapp/i18n/en/research-group.json index 286c53dc2d..1fc7fae69e 100644 --- a/src/main/webapp/i18n/en/research-group.json +++ b/src/main/webapp/i18n/en/research-group.json @@ -115,6 +115,7 @@ "manageMembers": "Manage Members", "addMembers": "Add Members", "addMembersButton": "Add members", + "roleLabel": "Role", "searchPlaceholder": "Search for a member using their name or email...", "singleEntity": "User", "multipleEntities": "Users", diff --git a/src/test/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.spec.ts b/src/test/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.spec.ts index 4c98638c26..0f382dce58 100644 --- a/src/test/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.spec.ts +++ b/src/test/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.spec.ts @@ -465,6 +465,7 @@ describe('ResearchGroupAddMembersComponent', () => { expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ keycloakUsers: [mockUser1, mockUser2], researchGroupId: 'research-group-1', + role: 'EMPLOYEE', }); expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledTimes(1); expect(mockToastService.showSuccessKey).toHaveBeenCalledWith('researchGroup.members.toastMessages.addMembersSuccess'); @@ -482,6 +483,7 @@ describe('ResearchGroupAddMembersComponent', () => { expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ keycloakUsers: [mockUser1], researchGroupId: 'research-group-1', + role: 'EMPLOYEE', }); expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledTimes(1); expect(mockToastService.showErrorKey).toHaveBeenCalledWith('researchGroup.members.toastMessages.addMembersFailed'); @@ -500,6 +502,7 @@ describe('ResearchGroupAddMembersComponent', () => { expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ keycloakUsers: [mockUser1], researchGroupId: 'research-group-1', + role: 'EMPLOYEE', }); expect(mockToastService.showErrorKey).toHaveBeenCalledWith('researchGroup.members.toastMessages.addMembersFailed'); expect(mockDialogRef.close).toHaveBeenCalledWith(false); @@ -520,6 +523,7 @@ describe('ResearchGroupAddMembersComponent', () => { expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ keycloakUsers: [mockUser1], researchGroupId: 'research-group-1', + role: 'EMPLOYEE', }); expect(mockToastService.showErrorKey).toHaveBeenCalledWith('researchGroup.members.toastMessages.addMembersFailedAlreadyMember'); expect(mockDialogRef.close).toHaveBeenCalledWith(false); @@ -539,6 +543,7 @@ describe('ResearchGroupAddMembersComponent', () => { expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ keycloakUsers: [mockUser1], researchGroupId: 'research-group-1', + role: 'EMPLOYEE', }); expect(mockToastService.showErrorKey).toHaveBeenCalledWith('researchGroup.members.toastMessages.addMembersFailedInvalidUniversityId'); expect(mockDialogRef.close).toHaveBeenCalledWith(false); @@ -554,11 +559,29 @@ describe('ResearchGroupAddMembersComponent', () => { expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ keycloakUsers: [mockUser1], researchGroupId: undefined, + role: 'EMPLOYEE', }); expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledTimes(1); }); }); + describe('Role Selection', () => { + it('should default the role to EMPLOYEE', () => { + expect(component.selectedRole()).toBe('EMPLOYEE'); + }); + + it('should pass the selected role through to the add-members API call', async () => { + component.toggleUserSelection(mockUser1); + component.toggleUserSelection(mockUser2); + component.selectedRole.set('PROFESSOR'); + mockResearchGroupService.addMembersToResearchGroup.mockReturnValue(of(void 0)); + + await component.onAddMembers(); + + expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith(expect.objectContaining({ role: 'PROFESSOR' })); + }); + }); + describe('State Persistence', () => { it('should maintain selection state across page changes', async () => { component.toggleUserSelection(mockUser1); From 77a04a2fc6600980254a57feb35484455ac549c0 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 8 May 2026 18:51:51 +0200 Subject: [PATCH 12/28] General: add Vitest specs for ManageUsers list and form Co-Authored-By: Claude Opus 4.7 (1M context) --- .../manage-user-form.component.spec.ts | 402 ++++++++++++++++++ .../manage-users-page.component.spec.ts | 349 +++++++++++++++ .../user-admin-resource-api.service.mock.ts | 27 ++ 3 files changed, 778 insertions(+) create mode 100644 src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts create mode 100644 src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts create mode 100644 src/test/webapp/util/user-admin-resource-api.service.mock.ts diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts new file mode 100644 index 0000000000..4536a1cf59 --- /dev/null +++ b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts @@ -0,0 +1,402 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ManageUserFormComponent } from 'app/usermanagement/manage-users/manage-user-form.component'; +import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; +import { AdminUserDetailDTO } from 'app/generated/model/admin-user-detail-dto'; + +import { provideToastServiceMock, createToastServiceMock, ToastServiceMock } from 'util/toast-service.mock'; +import { provideTranslateMock, createTranslateServiceMock } from 'util/translate.mock'; +import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; +import { + createUserAdminResourceApiMock, + provideUserAdminResourceApiMock, + UserAdminResourceApiMock, +} from 'util/user-admin-resource-api.service.mock'; + +/** + * Build a snapshot-only ActivatedRoute mock for a given set of route params and + * query params. The component only reads `snapshot.paramMap.get` and + * `snapshot.queryParamMap.get`, so a minimal stub is enough. + */ +function makeRoute(params: Record = {}, query: Record = {}): unknown { + return { + snapshot: { + paramMap: { get: (key: string) => params[key] ?? null }, + queryParamMap: { get: (key: string) => query[key] ?? null }, + }, + }; +} + +describe('ManageUserFormComponent', () => { + let mockUserAdminApi: UserAdminResourceApiMock; + let mockToastService: ToastServiceMock; + let mockRouter: { navigate: ReturnType }; + + const loadedUser: AdminUserDetailDTO = { + userId: 'user-1', + firstName: 'Alice', + lastName: 'Anderson', + email: 'alice@example.com', + universityId: 'TUM-001', + phoneNumber: '+49 89 1234', + website: 'https://alice.example', + linkedinUrl: 'https://linkedin.com/in/alice', + gender: 'female', + nationality: 'de', + selectedLanguage: 'de', + birthday: '1990-01-01', + }; + + /** + * Configure the TestBed with the requested route params/query params and + * return the resulting fixture + component. + */ + async function setupComponent( + params: Record = {}, + query: Record = {}, + ): Promise> { + TestBed.resetTestingModule(); + mockUserAdminApi = createUserAdminResourceApiMock(); + mockUserAdminApi.getUser.mockReturnValue(of(loadedUser)); + mockUserAdminApi.createUser.mockReturnValue(of({ userId: 'new-user-1' } as AdminUserDetailDTO)); + mockUserAdminApi.importUser.mockReturnValue(of({ userId: 'new-user-2' } as AdminUserDetailDTO)); + mockUserAdminApi.updateUser.mockReturnValue(of({ ...loadedUser, firstName: 'Alicia' })); + mockUserAdminApi.deleteUser.mockReturnValue(of(undefined)); + + mockToastService = createToastServiceMock(); + mockRouter = { navigate: vi.fn().mockResolvedValue(true) }; + + await TestBed.configureTestingModule({ + imports: [ManageUserFormComponent], + providers: [ + provideUserAdminResourceApiMock(mockUserAdminApi), + provideToastServiceMock(mockToastService), + provideTranslateMock(createTranslateServiceMock()), + provideFontAwesomeTesting(), + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: makeRoute(params, query) }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ManageUserFormComponent); + // Run the constructor `effect` so mode + validators get applied. + fixture.detectChanges(); + // Flush any pending microtasks for getUser in edit mode. + await Promise.resolve(); + await Promise.resolve(); + return fixture; + } + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Mode detection', () => { + it('should default to create mode when no params', async () => { + const fixture = await setupComponent(); + expect(fixture.componentInstance.mode()).toBe('create'); + expect(mockUserAdminApi.getUser).not.toHaveBeenCalled(); + expect(fixture.componentInstance.title()).toBe('manageUsersPage.form.createTitle'); + expect(fixture.componentInstance.submitLabel()).toBe('manageUsersPage.form.buttons.createUser'); + }); + + it('should set import mode when query mode=import', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + expect(fixture.componentInstance.mode()).toBe('import'); + expect(fixture.componentInstance.title()).toBe('manageUsersPage.form.importTitle'); + expect(fixture.componentInstance.submitLabel()).toBe('manageUsersPage.form.buttons.importUser'); + expect(mockUserAdminApi.getUser).not.toHaveBeenCalled(); + }); + + it('should set edit mode when userId route param is present and load the user', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + + expect(component.mode()).toBe('edit'); + expect(mockUserAdminApi.getUser).toHaveBeenCalledWith('user-1'); + expect(component.loadedUser()).toEqual(loadedUser); + expect(component.form.controls.firstName.value).toBe('Alice'); + expect(component.form.controls.lastName.value).toBe('Anderson'); + // Email control is disabled in edit mode but its raw value is the loaded email. + expect(component.form.getRawValue().email).toBe('alice@example.com'); + expect(component.form.controls.email.disabled).toBe(true); + expect(component.title()).toBe('manageUsersPage.form.editTitle'); + expect(component.submitLabel()).toBe('manageUsersPage.form.buttons.save'); + }); + + it('should toast on user-load failure in edit mode', async () => { + TestBed.resetTestingModule(); + mockUserAdminApi = createUserAdminResourceApiMock(); + mockUserAdminApi.getUser.mockReturnValue(throwError(() => new Error('not found'))); + mockToastService = createToastServiceMock(); + mockRouter = { navigate: vi.fn().mockResolvedValue(true) }; + + await TestBed.configureTestingModule({ + imports: [ManageUserFormComponent], + providers: [ + provideUserAdminResourceApiMock(mockUserAdminApi), + provideToastServiceMock(mockToastService), + provideTranslateMock(createTranslateServiceMock()), + provideFontAwesomeTesting(), + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: makeRoute({ userId: 'user-1' }) }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ManageUserFormComponent); + fixture.detectChanges(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockToastService.showErrorKey).toHaveBeenCalledWith('manageUsersPage.errors.loadUser'); + }); + }); + + describe('Validators per mode', () => { + it('should require password and identity fields in create mode', async () => { + const fixture = await setupComponent(); + const form = fixture.componentInstance.form; + + form.patchValue({ firstName: '', lastName: '', email: '', password: '' }); + expect(form.controls.firstName.hasError('required')).toBe(true); + expect(form.controls.lastName.hasError('required')).toBe(true); + expect(form.controls.email.hasError('required')).toBe(true); + expect(form.controls.password.hasError('required')).toBe(true); + + // Password complexity: must have lowercase, uppercase, digit, length >= 8. + form.controls.password.setValue('short1A'); + expect(form.controls.password.hasError('pattern')).toBe(true); + form.controls.password.setValue('LongEnough1'); + expect(form.controls.password.errors).toBeNull(); + }); + + it('should require only keycloakUserId in import mode and skip password', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const form = fixture.componentInstance.form; + + form.patchValue({ firstName: '', lastName: '', email: '', password: '', keycloakUserId: '' }); + + expect(form.controls.keycloakUserId.hasError('required')).toBe(true); + expect(form.controls.firstName.errors).toBeNull(); + expect(form.controls.lastName.errors).toBeNull(); + expect(form.controls.email.errors).toBeNull(); + expect(form.controls.password.errors).toBeNull(); + }); + + it('should not require password or keycloakUserId in edit mode', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const form = fixture.componentInstance.form; + + // Identity fields still required in edit mode. + form.patchValue({ firstName: '', lastName: '', password: '', keycloakUserId: '' }); + expect(form.controls.firstName.hasError('required')).toBe(true); + expect(form.controls.lastName.hasError('required')).toBe(true); + expect(form.controls.password.errors).toBeNull(); + expect(form.controls.keycloakUserId.errors).toBeNull(); + }); + }); + + describe('Submit', () => { + it('should call createUser in create mode and navigate to the new user page', async () => { + const fixture = await setupComponent(); + const component = fixture.componentInstance; + + component.form.patchValue({ + firstName: 'Carol', + lastName: 'Carter', + email: 'carol@example.com', + password: 'StrongPass1', + }); + + await component.onSubmit(); + + expect(mockUserAdminApi.createUser).toHaveBeenCalledTimes(1); + const dto = mockUserAdminApi.createUser.mock.calls[0][0]; + expect(dto.firstName).toBe('Carol'); + expect(dto.lastName).toBe('Carter'); + expect(dto.email).toBe('carol@example.com'); + expect(dto.password).toBe('StrongPass1'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users', 'new-user-1']); + expect(mockToastService.showSuccess).toHaveBeenCalled(); + }); + + it('should call importUser in import mode and navigate to the new user page', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + + component.form.patchValue({ keycloakUserId: 'kc-id-123' }); + + await component.onSubmit(); + + expect(mockUserAdminApi.importUser).toHaveBeenCalledWith({ keycloakUserId: 'kc-id-123' }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users', 'new-user-2']); + expect(mockToastService.showSuccess).toHaveBeenCalled(); + }); + + it('should call updateUser in edit mode and stay on the page', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + + component.form.patchValue({ firstName: 'Alicia' }); + + await component.onSubmit(); + + expect(mockUserAdminApi.updateUser).toHaveBeenCalledTimes(1); + const [calledUserId, dto] = mockUserAdminApi.updateUser.mock.calls[0]; + expect(calledUserId).toBe('user-1'); + expect(dto.firstName).toBe('Alicia'); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(component.loadedUser()?.firstName).toBe('Alicia'); + expect(mockToastService.showSuccess).toHaveBeenCalled(); + }); + + it('should mark form as touched and skip the API call when invalid', async () => { + const fixture = await setupComponent(); + const component = fixture.componentInstance; + + // Empty form is invalid in create mode. + await component.onSubmit(); + + expect(mockUserAdminApi.createUser).not.toHaveBeenCalled(); + expect(component.form.controls.firstName.touched).toBe(true); + }); + + it('should toast error on create failure', async () => { + const fixture = await setupComponent(); + const component = fixture.componentInstance; + + mockUserAdminApi.createUser.mockReturnValue(throwError(() => new Error('boom'))); + component.form.patchValue({ + firstName: 'Carol', + lastName: 'Carter', + email: 'carol@example.com', + password: 'StrongPass1', + }); + + await component.onSubmit(); + + expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should toast error on import failure', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + + mockUserAdminApi.importUser.mockReturnValue(throwError(() => new Error('boom'))); + component.form.patchValue({ keycloakUserId: 'kc-id-123' }); + + await component.onSubmit(); + + expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should toast error on update failure', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + + mockUserAdminApi.updateUser.mockReturnValue(throwError(() => new Error('boom'))); + component.form.patchValue({ firstName: 'Alicia' }); + + await component.onSubmit(); + + expect(mockToastService.showError).toHaveBeenCalled(); + }); + }); + + describe('Delete', () => { + it('should open the delete dialog only in edit mode', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + + component.onRequestDelete(); + expect(component.showDeleteDialog()).toBe(true); + }); + + it('should ignore delete requests in create or import mode', async () => { + const createFixture = await setupComponent(); + createFixture.componentInstance.onRequestDelete(); + expect(createFixture.componentInstance.showDeleteDialog()).toBe(false); + + const importFixture = await setupComponent({}, { mode: 'import' }); + importFixture.componentInstance.onRequestDelete(); + expect(importFixture.componentInstance.showDeleteDialog()).toBe(false); + }); + + it('should call deleteUser after confirm and navigate to the list', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + + await component.onConfirmDelete(); + + expect(mockUserAdminApi.deleteUser).toHaveBeenCalledWith('user-1'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users']); + expect(mockToastService.showSuccess).toHaveBeenCalled(); + }); + + it('should toast error when delete fails', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + + mockUserAdminApi.deleteUser.mockReturnValueOnce(throwError(() => new Error('forbidden'))); + // Stop the success path navigation from the earlier setup. + mockRouter.navigate.mockClear(); + + await component.onConfirmDelete(); + + expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalledWith(['/manage-users']); + }); + + it('should be a no-op when no user is loaded', async () => { + const fixture = await setupComponent(); + const component = fixture.componentInstance; + + await component.onConfirmDelete(); + + expect(mockUserAdminApi.deleteUser).not.toHaveBeenCalled(); + }); + }); + + describe('Navigation', () => { + it('should navigate back to the list', async () => { + const fixture = await setupComponent(); + fixture.componentInstance.onBack(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users']); + }); + }); + + describe('Select option helpers', () => { + it('should keep the selected gender, nationality, language, and birthday signals in sync', async () => { + const fixture = await setupComponent(); + const component = fixture.componentInstance; + + component.updateGender({ value: 'female', name: 'genders.female' }); + component.updateNationality({ value: 'de', name: 'nationality.de' }); + component.updateLanguage({ value: 'en', name: 'languages.en' }); + component.updateBirthday('1990-01-01'); + + expect(component.selectedGender()?.value).toBe('female'); + expect(component.selectedNationality()?.value).toBe('de'); + expect(component.selectedLanguage()?.value).toBe('en'); + expect(component.birthday()).toBe('1990-01-01'); + + component.updateBirthday(undefined); + expect(component.birthday()).toBe(''); + }); + + it('should populate select option signals from the loaded user in edit mode', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + + expect(component.selectedGender()?.value).toBe('female'); + expect(component.selectedNationality()?.value).toBe('de'); + expect(component.selectedLanguage()?.value).toBe('de'); + expect(component.birthday()).toBe('1990-01-01'); + }); + }); +}); diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts new file mode 100644 index 0000000000..05747adb7f --- /dev/null +++ b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts @@ -0,0 +1,349 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { TableLazyLoadEvent } from 'primeng/table'; +import { of, throwError } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ManageUsersPageComponent } from 'app/usermanagement/manage-users/manage-users-page.component'; +import { AccountService } from 'app/core/auth/account.service'; +import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; +import { ResearchGroupResourceApi } from 'app/generated/api/research-group-resource-api'; +import { AdminUserOverviewDTO } from 'app/generated/model/admin-user-overview-dto'; +import { FilterChange } from 'app/shared/components/atoms/filter-multiselect/filter-multiselect'; + +import { provideToastServiceMock, createToastServiceMock, ToastServiceMock } from 'util/toast-service.mock'; +import { provideTranslateMock, createTranslateServiceMock } from 'util/translate.mock'; +import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; +import { + createUserAdminResourceApiMock, + provideUserAdminResourceApiMock, + UserAdminResourceApiMock, +} from 'util/user-admin-resource-api.service.mock'; +import { + createResearchGroupResourceApiMock, + provideResearchGroupResourceApiMock, + ResearchGroupResourceApiMock, +} from 'util/research-group-resource-api.service.mock'; + +describe('ManageUsersPageComponent', () => { + let component: ManageUsersPageComponent; + let fixture: ComponentFixture; + let mockUserAdminApi: UserAdminResourceApiMock; + let mockResearchGroupApi: ResearchGroupResourceApiMock; + let mockToastService: ToastServiceMock; + let mockRouter: { navigate: ReturnType }; + let mockAccountService: { loadedUser: ReturnType }; + + const userAlice: AdminUserOverviewDTO = { + userId: 'user-1', + firstName: 'Alice', + lastName: 'Anderson', + email: 'alice@example.com', + primaryRole: 'PROFESSOR', + researchGroupName: 'AI Lab', + researchGroupId: 'rg-1', + }; + + const userBob: AdminUserOverviewDTO = { + userId: 'admin-id', + firstName: 'Bob', + lastName: 'Burton', + email: 'bob@example.com', + primaryRole: 'ADMIN', + }; + + const defaultPage = { content: [userAlice, userBob], totalElements: 2 }; + + const defaultResearchGroupPage = { + content: [ + { id: 'rg-1', researchGroup: 'AI Lab' }, + { id: 'rg-2', researchGroup: 'ML Lab' }, + ], + totalElements: 2, + }; + + beforeEach(async () => { + mockUserAdminApi = createUserAdminResourceApiMock(); + mockUserAdminApi.getAllUsers.mockReturnValue(of(defaultPage)); + mockUserAdminApi.deleteUser.mockReturnValue(of(undefined)); + + mockResearchGroupApi = createResearchGroupResourceApiMock(); + mockResearchGroupApi.getResearchGroupsForAdmin.mockReturnValue(of(defaultResearchGroupPage)); + + mockToastService = createToastServiceMock(); + mockRouter = { navigate: vi.fn().mockResolvedValue(true) }; + mockAccountService = { + loadedUser: vi.fn().mockReturnValue({ id: 'admin-id', name: 'Bob Burton' }), + }; + + await TestBed.configureTestingModule({ + imports: [ManageUsersPageComponent], + providers: [ + provideUserAdminResourceApiMock(mockUserAdminApi), + provideResearchGroupResourceApiMock(mockResearchGroupApi), + provideToastServiceMock(mockToastService), + provideTranslateMock(createTranslateServiceMock()), + provideFontAwesomeTesting(), + { provide: Router, useValue: mockRouter }, + { provide: AccountService, useValue: mockAccountService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ManageUsersPageComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load research group options on init', async () => { + await Promise.resolve(); + await Promise.resolve(); + + expect(mockResearchGroupApi.getResearchGroupsForAdmin).toHaveBeenCalledWith(1000, 0); + const options = component.researchGroupOptions(); + expect(options).toEqual([ + { id: 'rg-1', name: 'AI Lab' }, + { id: 'rg-2', name: 'ML Lab' }, + ]); + }); + + it('should fall back to error toast when research group load fails', async () => { + mockResearchGroupApi.getResearchGroupsForAdmin.mockReturnValue(throwError(() => new Error('boom'))); + + const failingFixture = TestBed.createComponent(ManageUsersPageComponent); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockToastService.showErrorKey).toHaveBeenCalledWith('manageUsersPage.errors.loadFilters'); + failingFixture.destroy(); + }); + + it('should load users on lazy load', async () => { + await component.loadPage({ first: 0, rows: 10 } as TableLazyLoadEvent); + + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith(10, 0, 'lastActivityAt', 'DESC', undefined, undefined, undefined); + expect(component.users()).toEqual([userAlice, userBob]); + expect(component.totalRecords()).toBe(2); + }); + + it('should compute page index from first and rows on lazy load', async () => { + await component.loadPage({ first: 40, rows: 20 } as TableLazyLoadEvent); + + expect(component.pageSize()).toBe(20); + expect(component.page()).toBe(2); + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith(20, 2, 'lastActivityAt', 'DESC', undefined, undefined, undefined); + }); + + it('should show error toast when loading users fails', async () => { + mockUserAdminApi.getAllUsers.mockReturnValueOnce(throwError(() => new Error('fail'))); + + await component.loadPage({ first: 0, rows: 10 } as TableLazyLoadEvent); + + expect(mockToastService.showErrorKey).toHaveBeenCalledWith('manageUsersPage.errors.loadUsers'); + }); + }); + + describe('Columns', () => { + it('should expose User, Email, University ID, Role, Research Group, Last Activity, and Actions columns', () => { + fixture.detectChanges(); + const columns = component.columns(); + const fields = columns.map(column => column.field); + + expect(fields).toEqual(['fullName', 'email', 'universityId', 'primaryRole', 'researchGroupName', 'lastActivityAt', 'actions']); + expect(columns[0].header).toBe('manageUsersPage.tableColumn.user'); + expect(columns[1].header).toBe('manageUsersPage.tableColumn.email'); + expect(columns[2].header).toBe('manageUsersPage.tableColumn.universityId'); + expect(columns[3].header).toBe('manageUsersPage.tableColumn.role'); + expect(columns[4].header).toBe('manageUsersPage.tableColumn.researchGroup'); + expect(columns[5].header).toBe('manageUsersPage.tableColumn.lastActivity'); + expect(columns[6].header).toBe(''); + }); + }); + + describe('Filters', () => { + it('should set role filter and reload with the selected enum array', async () => { + mockUserAdminApi.getAllUsers.mockClear(); + const change: FilterChange = { + filterId: 'role', + selectedValues: ['manageUsersPage.roles.PROFESSOR', 'manageUsersPage.roles.ADMIN'], + }; + + component.onFilterEmit(change); + await Promise.resolve(); + + expect(component.selectedRoleFilters()).toEqual(['PROFESSOR', 'ADMIN']); + expect(component.page()).toBe(0); + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith( + 10, + 0, + 'lastActivityAt', + 'DESC', + ['PROFESSOR', 'ADMIN'], + undefined, + undefined, + ); + }); + + it('should map research-group names to ids on filter change', async () => { + // Wait for research group load to complete first. + await Promise.resolve(); + await Promise.resolve(); + mockUserAdminApi.getAllUsers.mockClear(); + + const change: FilterChange = { + filterId: 'researchGroup', + selectedValues: ['AI Lab', 'ML Lab'], + }; + + component.onFilterEmit(change); + await Promise.resolve(); + + expect(component.selectedResearchGroupFilters()).toEqual(['rg-1', 'rg-2']); + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith( + 10, + 0, + 'lastActivityAt', + 'DESC', + undefined, + ['rg-1', 'rg-2'], + undefined, + ); + }); + }); + + describe('Search', () => { + it('should reload on meaningful query change', async () => { + mockUserAdminApi.getAllUsers.mockClear(); + + component.onSearchEmit('alice'); + await Promise.resolve(); + + expect(component.searchQuery()).toBe('alice'); + expect(component.page()).toBe(0); + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith( + 10, + 0, + 'lastActivityAt', + 'DESC', + undefined, + undefined, + 'alice', + ); + }); + + it('should NOT reload when query is unchanged', async () => { + component.searchQuery.set('same'); + mockUserAdminApi.getAllUsers.mockClear(); + + component.onSearchEmit('same'); + await Promise.resolve(); + + expect(mockUserAdminApi.getAllUsers).not.toHaveBeenCalled(); + }); + }); + + describe('Actions', () => { + it('should navigate to /manage-users/:userId on view', () => { + component.onViewUser('user-1'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users', 'user-1']); + }); + + it('should not navigate when view is called with empty id', () => { + component.onViewUser(''); + component.onViewUser(undefined); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should navigate to /manage-users/create on Create button', () => { + component.onCreateUser(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users/create']); + }); + + it('should navigate to /manage-users/create with mode=import on Import button', () => { + component.onImport(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users/create'], { queryParams: { mode: 'import' } }); + }); + + it('should request delete and open dialog for non-self row', () => { + component.onRequestDelete(userAlice); + + expect(component.currentUserToDelete()).toBe(userAlice); + expect(component.showDeleteDialog()).toBe(true); + expect(mockToastService.showErrorKey).not.toHaveBeenCalled(); + }); + + it('should disable delete on the current admin own row by toasting and skipping the dialog', () => { + component.onRequestDelete(userBob); + + expect(component.currentUserToDelete()).toBeUndefined(); + expect(component.showDeleteDialog()).toBe(false); + expect(mockToastService.showErrorKey).toHaveBeenCalledWith('manageUsersPage.errors.selfDelete'); + }); + + it('should treat the current admin row as self', () => { + expect(component.isSelf(userBob)).toBe(true); + expect(component.isSelf(userAlice)).toBe(false); + }); + + it('should call deleteUser on confirmed delete', async () => { + component.currentUserToDelete.set(userAlice); + + await component.onConfirmDelete(); + + expect(mockUserAdminApi.deleteUser).toHaveBeenCalledWith('user-1'); + expect(mockToastService.showSuccess).toHaveBeenCalled(); + expect(component.currentUserToDelete()).toBeUndefined(); + }); + + it('should not call deleteUser when no user is selected for deletion', async () => { + component.currentUserToDelete.set(undefined); + + await component.onConfirmDelete(); + + expect(mockUserAdminApi.deleteUser).not.toHaveBeenCalled(); + }); + + it('should toast error when delete fails', async () => { + mockUserAdminApi.deleteUser.mockReturnValueOnce(throwError(() => new Error('forbidden'))); + component.currentUserToDelete.set(userAlice); + + await component.onConfirmDelete(); + + expect(mockToastService.showError).toHaveBeenCalled(); + expect(component.currentUserToDelete()).toBeUndefined(); + }); + }); + + describe('Sort', () => { + it('should reload with the new sort field and direction', async () => { + mockUserAdminApi.getAllUsers.mockClear(); + + component.onSortEmit({ field: 'lastName', direction: 'ASC' }); + await Promise.resolve(); + + expect(component.sortBy()).toBe('lastName'); + expect(component.sortDirection()).toBe('ASC'); + expect(component.page()).toBe(0); + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith(10, 0, 'lastName', 'ASC', undefined, undefined, undefined); + }); + }); + + describe('Display helpers', () => { + it('should compute fullName from first and last name', () => { + expect(component.fullName(userAlice)).toBe('Alice Anderson'); + expect(component.fullName({ userId: 'x' } as AdminUserOverviewDTO)).toBe(''); + }); + + it('should map the row role to the matching translation key', () => { + expect(component.roleLabelKey(userAlice)).toBe('manageUsersPage.roles.PROFESSOR'); + expect(component.roleLabelKey({ userId: 'x' } as AdminUserOverviewDTO)).toBe(''); + }); + }); +}); diff --git a/src/test/webapp/util/user-admin-resource-api.service.mock.ts b/src/test/webapp/util/user-admin-resource-api.service.mock.ts new file mode 100644 index 0000000000..cae732ce16 --- /dev/null +++ b/src/test/webapp/util/user-admin-resource-api.service.mock.ts @@ -0,0 +1,27 @@ +import { Provider } from '@angular/core'; +import { vi, Mock } from 'vitest'; +import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; + +export interface UserAdminResourceApiMock { + createUser: Mock; + deleteUser: Mock; + getAllUsers: Mock; + getUser: Mock; + importUser: Mock; + updateUser: Mock; +} + +export function createUserAdminResourceApiMock(): UserAdminResourceApiMock { + return { + createUser: vi.fn(), + deleteUser: vi.fn(), + getAllUsers: vi.fn(), + getUser: vi.fn(), + importUser: vi.fn(), + updateUser: vi.fn(), + }; +} + +export function provideUserAdminResourceApiMock(mock: UserAdminResourceApiMock = createUserAdminResourceApiMock()): Provider { + return { provide: UserAdminResourceApi, useValue: mock }; +} From 3f5421a2b713e6c7bd8e72c3889096237c489a77 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sat, 9 May 2026 13:15:49 +0200 Subject: [PATCH 13/28] Bugfix: fetch user with research-group roles for admin detail view The detail view streams `user.researchGroupRoles` to derive the primary role, but `findById` returns a User whose roles collection is lazy. By the time the stream runs the Hibernate session is gone and the lookup 500s. Use the existing JOIN-FETCH finder used by UserRetentionService. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tum/cit/aet/usermanagement/service/UserAdminService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java index 0fcbc81626..9cc059a9d9 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java @@ -75,7 +75,9 @@ public Page getAllUsersForAdmin( * @throws EntityNotFoundException if no user exists with the given ID */ public AdminUserDetailDTO getUserDetail(UUID userId) { - User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); + User user = userRepository + .findWithResearchGroupRolesByUserId(userId) + .orElseThrow(() -> EntityNotFoundException.forId("User", userId)); UserRole primaryRole = user.getResearchGroupRoles() == null ? null : user From ecdce92c4f3582ade8b19ff1fcb3e922653503fe Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sat, 9 May 2026 13:27:48 +0200 Subject: [PATCH 14/28] translation keys --- src/main/webapp/i18n/de/usermanagement.json | 8 ++++---- src/main/webapp/i18n/en/usermanagement.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/webapp/i18n/de/usermanagement.json b/src/main/webapp/i18n/de/usermanagement.json index a1ade43f25..54f664fcf9 100644 --- a/src/main/webapp/i18n/de/usermanagement.json +++ b/src/main/webapp/i18n/de/usermanagement.json @@ -78,13 +78,13 @@ }, "toastMessages": { "createSuccess": "Nutzende angelegt.", - "createFailed": "Nutzende konnten nicht angelegt werden: {{ detail }}", + "createFailed": "Nutzende konnten nicht angelegt werden: {detail}", "updateSuccess": "Nutzende aktualisiert.", - "updateFailed": "Nutzende konnten nicht aktualisiert werden: {{ detail }}", + "updateFailed": "Nutzende konnten nicht aktualisiert werden: {detail}", "deleteSuccess": "Nutzende gelöscht.", - "deleteFailed": "Nutzende konnten nicht gelöscht werden: {{ detail }}", + "deleteFailed": "Nutzende konnten nicht gelöscht werden: {detail}", "importSuccess": "Nutzende aus Keycloak importiert.", - "importFailed": "Nutzende konnten nicht importiert werden: {{ detail }}" + "importFailed": "Nutzende konnten nicht importiert werden: {detail}" } } } diff --git a/src/main/webapp/i18n/en/usermanagement.json b/src/main/webapp/i18n/en/usermanagement.json index 40e2ccc257..7fd62d5157 100644 --- a/src/main/webapp/i18n/en/usermanagement.json +++ b/src/main/webapp/i18n/en/usermanagement.json @@ -78,13 +78,13 @@ }, "toastMessages": { "createSuccess": "User created.", - "createFailed": "Could not create user: {{ detail }}", + "createFailed": "Could not create user: {detail}", "updateSuccess": "User updated.", - "updateFailed": "Could not update user: {{ detail }}", + "updateFailed": "Could not update user: {detail}", "deleteSuccess": "User deleted.", - "deleteFailed": "Could not delete user: {{ detail }}", + "deleteFailed": "Could not delete user: {detail}", "importSuccess": "User imported from Keycloak.", - "importFailed": "Could not import user: {{ detail }}" + "importFailed": "Could not import user: {detail}" } } } From 85f10fb73acb162a72ed39cb9d560e6ef86dfb67 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sun, 10 May 2026 20:32:52 +0200 Subject: [PATCH 15/28] General: apply Prettier formatting to manage-users files Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/UserAdminService.java | 17 ++++++++-------- .../service/UserAdminServiceTest.java | 3 +-- .../web/rest/ResearchGroupResourceTest.java | 12 +++++------ .../manage-users-page.component.spec.ts | 20 ++----------------- 4 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java index 9cc059a9d9..8d5be479a6 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java @@ -78,14 +78,15 @@ public AdminUserDetailDTO getUserDetail(UUID userId) { User user = userRepository .findWithResearchGroupRolesByUserId(userId) .orElseThrow(() -> EntityNotFoundException.forId("User", userId)); - UserRole primaryRole = user.getResearchGroupRoles() == null - ? null - : user - .getResearchGroupRoles() - .stream() - .map(r -> r.getRole()) - .max(Comparator.comparingInt(UserAdminService::priority)) - .orElse(null); + UserRole primaryRole = + user.getResearchGroupRoles() == null + ? null + : user + .getResearchGroupRoles() + .stream() + .map(r -> r.getRole()) + .max(Comparator.comparingInt(UserAdminService::priority)) + .orElse(null); UUID rgId = user.getResearchGroup() == null ? null : user.getResearchGroup().getResearchGroupId(); String rgName = user.getResearchGroup() == null ? null : user.getResearchGroup().getName(); return new AdminUserDetailDTO( diff --git a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java index 393e7bdfd9..380099f9e5 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java @@ -48,8 +48,7 @@ class UserAdminServiceTest { @Test void shouldCreateKeycloakUserThenSyncDb() { UUID newId = UUID.randomUUID(); - when(keycloakUserService.createUserWithPassword(eq("a@b.com"), eq("Alice"), eq("Apple"), eq("hunter2!Long"))) - .thenReturn(newId); + when(keycloakUserService.createUserWithPassword(eq("a@b.com"), eq("Alice"), eq("Apple"), eq("hunter2!Long"))).thenReturn(newId); User user = new User(); user.setUserId(newId); when(userService.upsertUser(eq(newId.toString()), eq("a@b.com"), eq("Alice"), eq("Apple"))).thenReturn(user); diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java index 695151650e..a7d7194cb3 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java @@ -1118,12 +1118,12 @@ void shouldAssignProfessorRoleWhenSpecified() { .postAndRead(API_BASE_PATH + "/members", dto, Void.class, 204); User updatedUser = userRepository.findById(userToAdd.getUserId()).orElseThrow(); - assertThat(userResearchGroupRoleRepository.findAllByUser(updatedUser)) - .anyMatch(role -> + assertThat(userResearchGroupRoleRepository.findAllByUser(updatedUser)).anyMatch( + role -> role.getRole() == UserRole.PROFESSOR && role.getResearchGroup() != null && role.getResearchGroup().getResearchGroupId().equals(researchGroup.getResearchGroupId()) - ); + ); } @Test @@ -1138,12 +1138,12 @@ void shouldDefaultToEmployeeWhenRoleNull() { .postAndRead(API_BASE_PATH + "/members", dto, Void.class, 204); User updatedUser = userRepository.findById(userToAdd.getUserId()).orElseThrow(); - assertThat(userResearchGroupRoleRepository.findAllByUser(updatedUser)) - .anyMatch(role -> + assertThat(userResearchGroupRoleRepository.findAllByUser(updatedUser)).anyMatch( + role -> role.getRole() == UserRole.EMPLOYEE && role.getResearchGroup() != null && role.getResearchGroup().getResearchGroupId().equals(researchGroup.getResearchGroupId()) - ); + ); } } } diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts index 05747adb7f..fa2848f8a6 100644 --- a/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts +++ b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts @@ -206,15 +206,7 @@ describe('ManageUsersPageComponent', () => { await Promise.resolve(); expect(component.selectedResearchGroupFilters()).toEqual(['rg-1', 'rg-2']); - expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith( - 10, - 0, - 'lastActivityAt', - 'DESC', - undefined, - ['rg-1', 'rg-2'], - undefined, - ); + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith(10, 0, 'lastActivityAt', 'DESC', undefined, ['rg-1', 'rg-2'], undefined); }); }); @@ -227,15 +219,7 @@ describe('ManageUsersPageComponent', () => { expect(component.searchQuery()).toBe('alice'); expect(component.page()).toBe(0); - expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith( - 10, - 0, - 'lastActivityAt', - 'DESC', - undefined, - undefined, - 'alice', - ); + expect(mockUserAdminApi.getAllUsers).toHaveBeenCalledWith(10, 0, 'lastActivityAt', 'DESC', undefined, undefined, 'alice'); }); it('should NOT reload when query is unchanged', async () => { From 0ed448dfb2e3126a097abf818d4ae2aa21645f06 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sun, 10 May 2026 20:41:17 +0200 Subject: [PATCH 16/28] Test: cover UserAdminService listing, detail, update, and optional create-field branches Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/UserAdminServiceTest.java | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java index 380099f9e5..bbf768436f 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java @@ -107,4 +107,242 @@ void shouldDeleteFromKeycloakThenAnonymiseDb() { verify(keycloakUserService).deleteUser(targetId); verify(userRetentionService).deleteUserByAdmin(targetId); } + + @Test + void shouldDelegateListingToRepositoryWithNormalizedSearchAndNullableFilters() { + var pageDto = new de.tum.cit.aet.core.dto.PageDTO(25, 0); + var sortDto = new de.tum.cit.aet.core.dto.SortDTO("firstName", de.tum.cit.aet.core.dto.SortDTO.Direction.ASC); + org.springframework.data.domain.Page emptyPage = + org.springframework.data.domain.Page.empty(); + org.mockito.Mockito.when( + userRepository.findAllUsersForAdmin( + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any() + ) + ).thenReturn(emptyPage); + + var result = userAdminService.getAllUsersForAdmin(pageDto, sortDto, java.util.List.of(), java.util.List.of(), " alice "); + + assertThat(result).isSameAs(emptyPage); + org.mockito.Mockito.verify(userRepository).findAllUsersForAdmin( + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any() + ); + } + + @Test + void shouldPassThroughRolesAndResearchGroupsWhenFiltersProvided() { + var pageDto = new de.tum.cit.aet.core.dto.PageDTO(25, 0); + var sortDto = new de.tum.cit.aet.core.dto.SortDTO("firstName", de.tum.cit.aet.core.dto.SortDTO.Direction.ASC); + var roles = java.util.List.of(de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR); + var rgIds = java.util.List.of(java.util.UUID.randomUUID()); + org.springframework.data.domain.Page emptyPage = + org.springframework.data.domain.Page.empty(); + org.mockito.Mockito.when( + userRepository.findAllUsersForAdmin( + org.mockito.ArgumentMatchers.eq(roles), + org.mockito.ArgumentMatchers.eq(rgIds), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ) + ).thenReturn(emptyPage); + + userAdminService.getAllUsersForAdmin(pageDto, sortDto, roles, rgIds, null); + + org.mockito.Mockito.verify(userRepository).findAllUsersForAdmin( + org.mockito.ArgumentMatchers.eq(roles), + org.mockito.ArgumentMatchers.eq(rgIds), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + @Test + void shouldReturnDetailWithHighestPriorityRoleAndResearchGroup() { + java.util.UUID id = java.util.UUID.randomUUID(); + java.util.UUID rgId = java.util.UUID.randomUUID(); + + var rg = new de.tum.cit.aet.usermanagement.domain.ResearchGroup(); + rg.setResearchGroupId(rgId); + rg.setName("Test RG"); + + var employeeRole = new de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole(); + employeeRole.setRole(de.tum.cit.aet.usermanagement.constants.UserRole.EMPLOYEE); + var professorRole = new de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole(); + professorRole.setRole(de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR); + + var user = new de.tum.cit.aet.usermanagement.domain.User(); + user.setUserId(id); + user.setFirstName("Alice"); + user.setLastName("Apple"); + user.setEmail("a@b.com"); + user.setResearchGroup(rg); + user.setResearchGroupRoles(new java.util.HashSet<>(java.util.Set.of(employeeRole, professorRole))); + + org.mockito.Mockito.when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(java.util.Optional.of(user)); + + var detail = userAdminService.getUserDetail(id); + + assertThat(detail.userId()).isEqualTo(id); + assertThat(detail.firstName()).isEqualTo("Alice"); + assertThat(detail.primaryRole()).isEqualTo(de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR); + assertThat(detail.researchGroupId()).isEqualTo(rgId); + assertThat(detail.researchGroupName()).isEqualTo("Test RG"); + } + + @Test + void shouldReturnDetailWithNullRoleAndResearchGroupWhenAbsent() { + java.util.UUID id = java.util.UUID.randomUUID(); + var user = new de.tum.cit.aet.usermanagement.domain.User(); + user.setUserId(id); + user.setFirstName("Bob"); + user.setLastName("Banana"); + user.setEmail("b@b.com"); + user.setResearchGroup(null); + user.setResearchGroupRoles(null); + org.mockito.Mockito.when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(java.util.Optional.of(user)); + + var detail = userAdminService.getUserDetail(id); + + assertThat(detail.primaryRole()).isNull(); + assertThat(detail.researchGroupId()).isNull(); + assertThat(detail.researchGroupName()).isNull(); + } + + @Test + void shouldThrowWhenUserDetailNotFound() { + java.util.UUID id = java.util.UUID.randomUUID(); + org.mockito.Mockito.when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> userAdminService.getUserDetail(id)).isInstanceOf( + de.tum.cit.aet.core.exception.EntityNotFoundException.class + ); + } + + @Test + void shouldUpdateAllFieldsWhenProvided() { + java.util.UUID id = java.util.UUID.randomUUID(); + var user = new de.tum.cit.aet.usermanagement.domain.User(); + user.setUserId(id); + org.mockito.Mockito.when(userRepository.findById(id)).thenReturn(java.util.Optional.of(user)); + + var dto = new de.tum.cit.aet.usermanagement.dto.UpdateUserDTO( + "Alice", + "Apple", + "uni-123", + "+49 0", + "FEMALE", + "DE", + java.time.LocalDate.of(1990, 1, 1), + "https://example.com", + "https://linkedin.com/in/alice", + "en", + Boolean.TRUE, + "avatar.png" + ); + + userAdminService.update(id, dto); + + assertThat(user.getFirstName()).isEqualTo("Alice"); + assertThat(user.getLastName()).isEqualTo("Apple"); + assertThat(user.getUniversityId()).isEqualTo("uni-123"); + assertThat(user.getPhoneNumber()).isEqualTo("+49 0"); + assertThat(user.getGender()).isEqualTo("FEMALE"); + assertThat(user.getNationality()).isEqualTo("DE"); + assertThat(user.getBirthday()).isEqualTo(java.time.LocalDate.of(1990, 1, 1)); + assertThat(user.getWebsite()).isEqualTo("https://example.com"); + assertThat(user.getLinkedinUrl()).isEqualTo("https://linkedin.com/in/alice"); + assertThat(user.getSelectedLanguage()).isEqualTo("en"); + assertThat(user.isAiFeaturesEnabled()).isTrue(); + assertThat(user.getAvatar()).isEqualTo("avatar.png"); + org.mockito.Mockito.verify(userRepository).save(user); + } + + @Test + void shouldLeaveFieldsUntouchedWhenUpdateDtoFieldsAreNull() { + java.util.UUID id = java.util.UUID.randomUUID(); + var user = new de.tum.cit.aet.usermanagement.domain.User(); + user.setUserId(id); + user.setFirstName("Original"); + user.setLastName("Name"); + org.mockito.Mockito.when(userRepository.findById(id)).thenReturn(java.util.Optional.of(user)); + + userAdminService.update( + id, + new de.tum.cit.aet.usermanagement.dto.UpdateUserDTO(null, null, null, null, null, null, null, null, null, null, null, null) + ); + + assertThat(user.getFirstName()).isEqualTo("Original"); + assertThat(user.getLastName()).isEqualTo("Name"); + org.mockito.Mockito.verify(userRepository).save(user); + } + + @Test + void shouldThrowWhenUpdateTargetNotFound() { + java.util.UUID id = java.util.UUID.randomUUID(); + org.mockito.Mockito.when(userRepository.findById(id)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> + userAdminService.update( + id, + new de.tum.cit.aet.usermanagement.dto.UpdateUserDTO("A", null, null, null, null, null, null, null, null, null, null, null) + ) + ).isInstanceOf(de.tum.cit.aet.core.exception.EntityNotFoundException.class); + } + + @Test + void shouldApplyOptionalCreateFieldsAndSaveWhenAnyProvided() { + java.util.UUID newId = java.util.UUID.randomUUID(); + var user = new de.tum.cit.aet.usermanagement.domain.User(); + user.setUserId(newId); + org.mockito.Mockito.when( + keycloakUserService.createUserWithPassword( + org.mockito.ArgumentMatchers.eq("a@b.com"), + org.mockito.ArgumentMatchers.eq("Alice"), + org.mockito.ArgumentMatchers.eq("Apple"), + org.mockito.ArgumentMatchers.eq("hunter2!Long") + ) + ).thenReturn(newId); + org.mockito.Mockito.when( + userService.upsertUser( + org.mockito.ArgumentMatchers.eq(newId.toString()), + org.mockito.ArgumentMatchers.eq("a@b.com"), + org.mockito.ArgumentMatchers.eq("Alice"), + org.mockito.ArgumentMatchers.eq("Apple") + ) + ).thenReturn(user); + org.mockito.Mockito.when(userRepository.findById(newId)).thenReturn(java.util.Optional.of(user)); + + var dto = new de.tum.cit.aet.usermanagement.dto.CreateUserDTO( + "Alice", + "Apple", + "a@b.com", + "hunter2!Long", + "uni-1", + "+49", + "FEMALE", + "DE", + java.time.LocalDate.of(1995, 5, 5), + "https://example.com", + "https://linkedin.com/in/a", + "en" + ); + + var created = userAdminService.create(dto); + + assertThat(created).isEqualTo(newId); + assertThat(user.getUniversityId()).isEqualTo("uni-1"); + assertThat(user.getPhoneNumber()).isEqualTo("+49"); + assertThat(user.getGender()).isEqualTo("FEMALE"); + assertThat(user.getNationality()).isEqualTo("DE"); + assertThat(user.getBirthday()).isEqualTo(java.time.LocalDate.of(1995, 5, 5)); + assertThat(user.getWebsite()).isEqualTo("https://example.com"); + assertThat(user.getLinkedinUrl()).isEqualTo("https://linkedin.com/in/a"); + assertThat(user.getSelectedLanguage()).isEqualTo("en"); + org.mockito.Mockito.verify(userRepository).save(user); + } } From 74468ef8d065d16654551b336f737207a330497d Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sun, 10 May 2026 20:45:46 +0200 Subject: [PATCH 17/28] Test: tidy UserAdminService test style and drop redundant verifications Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/UserAdminServiceTest.java | 182 ++++++++---------- 1 file changed, 75 insertions(+), 107 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java index bbf768436f..e4211d34d5 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java @@ -2,27 +2,42 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import de.tum.cit.aet.core.dto.PageDTO; +import de.tum.cit.aet.core.dto.SortDTO; import de.tum.cit.aet.core.exception.EntityNotFoundException; import de.tum.cit.aet.core.exception.OperationNotAllowedException; import de.tum.cit.aet.core.retention.UserRetentionService; import de.tum.cit.aet.core.service.CurrentUserService; +import de.tum.cit.aet.usermanagement.constants.UserRole; +import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.User; +import de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole; +import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; import de.tum.cit.aet.usermanagement.dto.KeycloakUserDTO; +import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; import de.tum.cit.aet.usermanagement.repository.UserRepository; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; @ExtendWith(MockitoExtension.class) class UserAdminServiceTest { @@ -110,101 +125,72 @@ void shouldDeleteFromKeycloakThenAnonymiseDb() { @Test void shouldDelegateListingToRepositoryWithNormalizedSearchAndNullableFilters() { - var pageDto = new de.tum.cit.aet.core.dto.PageDTO(25, 0); - var sortDto = new de.tum.cit.aet.core.dto.SortDTO("firstName", de.tum.cit.aet.core.dto.SortDTO.Direction.ASC); - org.springframework.data.domain.Page emptyPage = - org.springframework.data.domain.Page.empty(); - org.mockito.Mockito.when( - userRepository.findAllUsersForAdmin( - org.mockito.ArgumentMatchers.isNull(), - org.mockito.ArgumentMatchers.isNull(), - org.mockito.ArgumentMatchers.anyString(), - org.mockito.ArgumentMatchers.any() - ) - ).thenReturn(emptyPage); - - var result = userAdminService.getAllUsersForAdmin(pageDto, sortDto, java.util.List.of(), java.util.List.of(), " alice "); + var pageDto = new PageDTO(25, 0); + var sortDto = new SortDTO("firstName", SortDTO.Direction.ASC); + Page emptyPage = Page.empty(); + when(userRepository.findAllUsersForAdmin(isNull(), isNull(), anyString(), any())).thenReturn(emptyPage); + + var result = userAdminService.getAllUsersForAdmin(pageDto, sortDto, List.of(), List.of(), " alice "); assertThat(result).isSameAs(emptyPage); - org.mockito.Mockito.verify(userRepository).findAllUsersForAdmin( - org.mockito.ArgumentMatchers.isNull(), - org.mockito.ArgumentMatchers.isNull(), - org.mockito.ArgumentMatchers.anyString(), - org.mockito.ArgumentMatchers.any() - ); } @Test void shouldPassThroughRolesAndResearchGroupsWhenFiltersProvided() { - var pageDto = new de.tum.cit.aet.core.dto.PageDTO(25, 0); - var sortDto = new de.tum.cit.aet.core.dto.SortDTO("firstName", de.tum.cit.aet.core.dto.SortDTO.Direction.ASC); - var roles = java.util.List.of(de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR); - var rgIds = java.util.List.of(java.util.UUID.randomUUID()); - org.springframework.data.domain.Page emptyPage = - org.springframework.data.domain.Page.empty(); - org.mockito.Mockito.when( - userRepository.findAllUsersForAdmin( - org.mockito.ArgumentMatchers.eq(roles), - org.mockito.ArgumentMatchers.eq(rgIds), - org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any() - ) - ).thenReturn(emptyPage); - - userAdminService.getAllUsersForAdmin(pageDto, sortDto, roles, rgIds, null); - - org.mockito.Mockito.verify(userRepository).findAllUsersForAdmin( - org.mockito.ArgumentMatchers.eq(roles), - org.mockito.ArgumentMatchers.eq(rgIds), - org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any() - ); + var pageDto = new PageDTO(25, 0); + var sortDto = new SortDTO("firstName", SortDTO.Direction.ASC); + var roles = List.of(UserRole.PROFESSOR); + var rgIds = List.of(UUID.randomUUID()); + Page emptyPage = Page.empty(); + when(userRepository.findAllUsersForAdmin(eq(roles), eq(rgIds), any(), any())).thenReturn(emptyPage); + + var result = userAdminService.getAllUsersForAdmin(pageDto, sortDto, roles, rgIds, null); + + assertThat(result).isSameAs(emptyPage); } @Test void shouldReturnDetailWithHighestPriorityRoleAndResearchGroup() { - java.util.UUID id = java.util.UUID.randomUUID(); - java.util.UUID rgId = java.util.UUID.randomUUID(); + UUID id = UUID.randomUUID(); + UUID rgId = UUID.randomUUID(); - var rg = new de.tum.cit.aet.usermanagement.domain.ResearchGroup(); + var rg = new ResearchGroup(); rg.setResearchGroupId(rgId); rg.setName("Test RG"); - var employeeRole = new de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole(); - employeeRole.setRole(de.tum.cit.aet.usermanagement.constants.UserRole.EMPLOYEE); - var professorRole = new de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole(); - professorRole.setRole(de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR); + var employeeRole = new UserResearchGroupRole(); + employeeRole.setRole(UserRole.EMPLOYEE); + var professorRole = new UserResearchGroupRole(); + professorRole.setRole(UserRole.PROFESSOR); - var user = new de.tum.cit.aet.usermanagement.domain.User(); + var user = new User(); user.setUserId(id); user.setFirstName("Alice"); user.setLastName("Apple"); user.setEmail("a@b.com"); user.setResearchGroup(rg); - user.setResearchGroupRoles(new java.util.HashSet<>(java.util.Set.of(employeeRole, professorRole))); + user.setResearchGroupRoles(new HashSet<>(Set.of(employeeRole, professorRole))); - org.mockito.Mockito.when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(java.util.Optional.of(user)); + when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(Optional.of(user)); var detail = userAdminService.getUserDetail(id); assertThat(detail.userId()).isEqualTo(id); assertThat(detail.firstName()).isEqualTo("Alice"); - assertThat(detail.primaryRole()).isEqualTo(de.tum.cit.aet.usermanagement.constants.UserRole.PROFESSOR); + assertThat(detail.primaryRole()).isEqualTo(UserRole.PROFESSOR); assertThat(detail.researchGroupId()).isEqualTo(rgId); assertThat(detail.researchGroupName()).isEqualTo("Test RG"); } @Test void shouldReturnDetailWithNullRoleAndResearchGroupWhenAbsent() { - java.util.UUID id = java.util.UUID.randomUUID(); - var user = new de.tum.cit.aet.usermanagement.domain.User(); + UUID id = UUID.randomUUID(); + var user = new User(); user.setUserId(id); user.setFirstName("Bob"); user.setLastName("Banana"); user.setEmail("b@b.com"); - user.setResearchGroup(null); - user.setResearchGroupRoles(null); - org.mockito.Mockito.when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(java.util.Optional.of(user)); + when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(Optional.of(user)); var detail = userAdminService.getUserDetail(id); @@ -215,29 +201,27 @@ void shouldReturnDetailWithNullRoleAndResearchGroupWhenAbsent() { @Test void shouldThrowWhenUserDetailNotFound() { - java.util.UUID id = java.util.UUID.randomUUID(); - org.mockito.Mockito.when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(java.util.Optional.empty()); + UUID id = UUID.randomUUID(); + when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> userAdminService.getUserDetail(id)).isInstanceOf( - de.tum.cit.aet.core.exception.EntityNotFoundException.class - ); + assertThatThrownBy(() -> userAdminService.getUserDetail(id)).isInstanceOf(EntityNotFoundException.class); } @Test void shouldUpdateAllFieldsWhenProvided() { - java.util.UUID id = java.util.UUID.randomUUID(); - var user = new de.tum.cit.aet.usermanagement.domain.User(); + UUID id = UUID.randomUUID(); + var user = new User(); user.setUserId(id); - org.mockito.Mockito.when(userRepository.findById(id)).thenReturn(java.util.Optional.of(user)); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); - var dto = new de.tum.cit.aet.usermanagement.dto.UpdateUserDTO( + var dto = new UpdateUserDTO( "Alice", "Apple", "uni-123", "+49 0", "FEMALE", "DE", - java.time.LocalDate.of(1990, 1, 1), + LocalDate.of(1990, 1, 1), "https://example.com", "https://linkedin.com/in/alice", "en", @@ -253,71 +237,55 @@ void shouldUpdateAllFieldsWhenProvided() { assertThat(user.getPhoneNumber()).isEqualTo("+49 0"); assertThat(user.getGender()).isEqualTo("FEMALE"); assertThat(user.getNationality()).isEqualTo("DE"); - assertThat(user.getBirthday()).isEqualTo(java.time.LocalDate.of(1990, 1, 1)); + assertThat(user.getBirthday()).isEqualTo(LocalDate.of(1990, 1, 1)); assertThat(user.getWebsite()).isEqualTo("https://example.com"); assertThat(user.getLinkedinUrl()).isEqualTo("https://linkedin.com/in/alice"); assertThat(user.getSelectedLanguage()).isEqualTo("en"); assertThat(user.isAiFeaturesEnabled()).isTrue(); assertThat(user.getAvatar()).isEqualTo("avatar.png"); - org.mockito.Mockito.verify(userRepository).save(user); + verify(userRepository).save(user); } @Test void shouldLeaveFieldsUntouchedWhenUpdateDtoFieldsAreNull() { - java.util.UUID id = java.util.UUID.randomUUID(); - var user = new de.tum.cit.aet.usermanagement.domain.User(); + UUID id = UUID.randomUUID(); + var user = new User(); user.setUserId(id); user.setFirstName("Original"); user.setLastName("Name"); - org.mockito.Mockito.when(userRepository.findById(id)).thenReturn(java.util.Optional.of(user)); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); - userAdminService.update( - id, - new de.tum.cit.aet.usermanagement.dto.UpdateUserDTO(null, null, null, null, null, null, null, null, null, null, null, null) - ); + userAdminService.update(id, new UpdateUserDTO(null, null, null, null, null, null, null, null, null, null, null, null)); assertThat(user.getFirstName()).isEqualTo("Original"); assertThat(user.getLastName()).isEqualTo("Name"); - org.mockito.Mockito.verify(userRepository).save(user); + verify(userRepository).save(user); } @Test void shouldThrowWhenUpdateTargetNotFound() { - java.util.UUID id = java.util.UUID.randomUUID(); - org.mockito.Mockito.when(userRepository.findById(id)).thenReturn(java.util.Optional.empty()); + UUID id = UUID.randomUUID(); + when(userRepository.findById(id)).thenReturn(Optional.empty()); assertThatThrownBy(() -> - userAdminService.update( - id, - new de.tum.cit.aet.usermanagement.dto.UpdateUserDTO("A", null, null, null, null, null, null, null, null, null, null, null) - ) - ).isInstanceOf(de.tum.cit.aet.core.exception.EntityNotFoundException.class); + userAdminService.update(id, new UpdateUserDTO("A", null, null, null, null, null, null, null, null, null, null, null)) + ).isInstanceOf(EntityNotFoundException.class); } @Test void shouldApplyOptionalCreateFieldsAndSaveWhenAnyProvided() { - java.util.UUID newId = java.util.UUID.randomUUID(); - var user = new de.tum.cit.aet.usermanagement.domain.User(); + UUID newId = UUID.randomUUID(); + var user = new User(); user.setUserId(newId); - org.mockito.Mockito.when( - keycloakUserService.createUserWithPassword( - org.mockito.ArgumentMatchers.eq("a@b.com"), - org.mockito.ArgumentMatchers.eq("Alice"), - org.mockito.ArgumentMatchers.eq("Apple"), - org.mockito.ArgumentMatchers.eq("hunter2!Long") - ) + when( + keycloakUserService.createUserWithPassword(eq("a@b.com"), eq("Alice"), eq("Apple"), eq("hunter2!Long")) ).thenReturn(newId); - org.mockito.Mockito.when( - userService.upsertUser( - org.mockito.ArgumentMatchers.eq(newId.toString()), - org.mockito.ArgumentMatchers.eq("a@b.com"), - org.mockito.ArgumentMatchers.eq("Alice"), - org.mockito.ArgumentMatchers.eq("Apple") - ) + when( + userService.upsertUser(eq(newId.toString()), eq("a@b.com"), eq("Alice"), eq("Apple")) ).thenReturn(user); - org.mockito.Mockito.when(userRepository.findById(newId)).thenReturn(java.util.Optional.of(user)); + when(userRepository.findById(newId)).thenReturn(Optional.of(user)); - var dto = new de.tum.cit.aet.usermanagement.dto.CreateUserDTO( + var dto = new CreateUserDTO( "Alice", "Apple", "a@b.com", @@ -326,7 +294,7 @@ void shouldApplyOptionalCreateFieldsAndSaveWhenAnyProvided() { "+49", "FEMALE", "DE", - java.time.LocalDate.of(1995, 5, 5), + LocalDate.of(1995, 5, 5), "https://example.com", "https://linkedin.com/in/a", "en" @@ -339,10 +307,10 @@ void shouldApplyOptionalCreateFieldsAndSaveWhenAnyProvided() { assertThat(user.getPhoneNumber()).isEqualTo("+49"); assertThat(user.getGender()).isEqualTo("FEMALE"); assertThat(user.getNationality()).isEqualTo("DE"); - assertThat(user.getBirthday()).isEqualTo(java.time.LocalDate.of(1995, 5, 5)); + assertThat(user.getBirthday()).isEqualTo(LocalDate.of(1995, 5, 5)); assertThat(user.getWebsite()).isEqualTo("https://example.com"); assertThat(user.getLinkedinUrl()).isEqualTo("https://linkedin.com/in/a"); assertThat(user.getSelectedLanguage()).isEqualTo("en"); - org.mockito.Mockito.verify(userRepository).save(user); + verify(userRepository).save(user); } } From 8a4ecf4edaf00b3cd46aab4895d881d0fe215dc8 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sat, 16 May 2026 16:20:01 +0200 Subject: [PATCH 18/28] \`Bugfix\`: Migrate admin user-create paths to the per-realm Keycloak clients Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cit/aet/usermanagement/service/KeycloakUserService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java index 98d499ffb0..355d3c45b8 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/KeycloakUserService.java @@ -247,7 +247,7 @@ public Optional findUserIdByEmail(String email) { */ public Optional findKeycloakUserById(UUID userId) { try { - UserRepresentation rep = keycloak.realm(realm).users().get(userId.toString()).toRepresentation(); + UserRepresentation rep = externalKeycloak.realm(externalRealm).users().get(userId.toString()).toRepresentation(); if (rep == null) { return Optional.empty(); } @@ -333,7 +333,7 @@ public UUID createUserWithPassword(String email, String firstName, String lastNa newUser.setEnabled(true); newUser.setEmailVerified(true); - try (Response resp = keycloak.realm(realm).users().create(newUser)) { + try (Response resp = externalKeycloak.realm(externalRealm).users().create(newUser)) { if (resp.getStatus() == 201 && resp.getLocation() != null) { String path = resp.getLocation().getPath(); return path.substring(path.lastIndexOf('/') + 1); @@ -344,7 +344,7 @@ public UUID createUserWithPassword(String email, String firstName, String lastNa throw new IllegalStateException("Keycloak user create failed: status=" + resp.getStatus()); } }); - boolean passwordSet = setPassword(keycloakUserId, password); + boolean passwordSet = setPassword(keycloakUserId, password, null); if (!passwordSet) { throw new IllegalStateException("Keycloak password set failed for userId=" + keycloakUserId); } From 5107d1fbf6f1a1a19aead7720d8410853be8cc2d Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Thu, 21 May 2026 17:59:29 +0200 Subject: [PATCH 19/28] `General`: Address review feedback on Manage Users admin page - Drop @Transactional from single-repo UserAdminService methods - Add @JsonInclude to ImportUserDTO, CreateUserDTO, UpdateUserDTO - Use jhi-back-button instead of custom onBack in manage-user-form - Move per-row helpers into a userRows computed signal so the manage-users-page template no longer calls methods - Replace spread of role enum tuple with a readonly reference - Delete UserAdminServiceTest; coverage stays at UserAdminResourceTest - Switch client specs to provideRouterMock/provideActivatedRouteMock and drop ceremony tests Co-Authored-By: Claude Opus 4.7 (1M context) --- .../aet/usermanagement/dto/CreateUserDTO.java | 2 + .../aet/usermanagement/dto/ImportUserDTO.java | 2 + .../aet/usermanagement/dto/UpdateUserDTO.java | 2 + .../service/UserAdminService.java | 5 - .../manage-user-form.component.html | 2 +- .../manage-user-form.component.ts | 10 +- .../manage-users-page.component.html | 10 +- .../manage-users-page.component.ts | 69 ++-- .../service/UserAdminServiceTest.java | 316 ------------------ .../manage-user-form.component.spec.ts | 60 +--- .../manage-users-page.component.spec.ts | 67 ++-- 11 files changed, 105 insertions(+), 440 deletions(-) delete mode 100644 src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java index 792ee82028..9f4b8e4f03 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.usermanagement.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -9,6 +10,7 @@ * Request body for admin user creation. Required fields are sent to Keycloak; * optional fields are stored only in the local DB. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) public record CreateUserDTO( @NotBlank String firstName, @NotBlank String lastName, diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java index e1dd8fb6ec..e53f4da510 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.usermanagement.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.validation.constraints.NotNull; import java.util.UUID; @@ -8,4 +9,5 @@ * * @param keycloakUserId the Keycloak user id to import */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) public record ImportUserDTO(@NotNull UUID keycloakUserId) {} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java index f14bb9df2f..1bb4cf583e 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java @@ -1,11 +1,13 @@ package de.tum.cit.aet.usermanagement.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import java.time.LocalDate; /** * Request body for admin user edits. All fields optional. `email`, `password`, * and `userId` are not updatable from this DTO. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) public record UpdateUserDTO( String firstName, String lastName, diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java index 8d5be479a6..568493bbef 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java @@ -24,7 +24,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * Orchestrates Keycloak and local-DB user management for admins. @@ -120,7 +119,6 @@ public AdminUserDetailDTO getUserDetail(UUID userId) { * @param dto the create-user payload * @return the new user's UUID */ - @Transactional public UUID create(CreateUserDTO dto) { // 1) Keycloak first — failure aborts before any DB write. UUID userId = keycloakUserService.createUserWithPassword(dto.email(), dto.firstName(), dto.lastName(), dto.password()); @@ -138,7 +136,6 @@ public UUID create(CreateUserDTO dto) { * @return the imported user's UUID * @throws EntityNotFoundException if no Keycloak user exists with the given ID */ - @Transactional public UUID importFromKeycloak(ImportUserDTO dto) { KeycloakUserDTO kcUser = keycloakUserService .findKeycloakUserById(dto.keycloakUserId()) @@ -155,7 +152,6 @@ public UUID importFromKeycloak(ImportUserDTO dto) { * @param dto the update payload (any null field is left untouched) * @throws EntityNotFoundException if no user exists with the given ID */ - @Transactional public void update(UUID userId, UpdateUserDTO dto) { User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); if (dto.firstName() != null) { @@ -204,7 +200,6 @@ public void update(UUID userId, UpdateUserDTO dto) { * @param userId the user ID to delete * @throws OperationNotAllowedException if the caller targets their own account */ - @Transactional public void delete(UUID userId) { UUID currentUserId = currentUserService.getUserId(); if (userId.equals(currentUserId)) { diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html index 2566375149..2bd054857a 100644 --- a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html @@ -13,7 +13,7 @@
- +

@if (mode() === 'edit') { diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts index d477c66e38..c246f8ad6c 100644 --- a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts @@ -1,5 +1,5 @@ import { Component, computed, effect, inject, signal } from '@angular/core'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { DividerModule } from 'primeng/divider'; @@ -10,6 +10,7 @@ import { CreateUserDTO } from 'app/generated/model/create-user-dto'; import { ImportUserDTO } from 'app/generated/model/import-user-dto'; import { UpdateUserDTO } from 'app/generated/model/update-user-dto'; import { ToastService } from 'app/service/toast-service'; +import { BackButtonComponent } from 'app/shared/components/atoms/back-button/back-button.component'; import { ButtonComponent } from 'app/shared/components/atoms/button/button.component'; import { ConfirmDialog } from 'app/shared/components/atoms/confirm-dialog/confirm-dialog'; import { DatePickerComponent } from 'app/shared/components/atoms/datepicker/datepicker.component'; @@ -37,13 +38,13 @@ const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/; @Component({ selector: 'jhi-manage-user-form', imports: [ + BackButtonComponent, ButtonComponent, ConfirmDialog, DatePickerComponent, DividerModule, LocalizedDatePipe, ReactiveFormsModule, - RouterModule, SelectComponent, StringInputComponent, TranslateDirective, @@ -204,11 +205,6 @@ export class ManageUserFormComponent { } } - /** Navigate back to the list page. */ - onBack(): void { - void this.router.navigate(['/manage-users']); - } - updateGender(option: SelectOption | undefined): void { this.selectedGender.set(option); } diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html index f462eb930a..92089da612 100644 --- a/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html +++ b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html @@ -50,7 +50,7 @@

- - {{ fullName(row) }} + + {{ row.fullName }}
@if (row.primaryRole) { - + } @@ -84,7 +84,7 @@

icon="trash" severity="danger" variant="text" - [disabled]="isSelf(row)" + [disabled]="row.isSelf" (click)="onRequestDelete(row)" />
diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts index e081e083ca..f1902eb88d 100644 --- a/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts +++ b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts @@ -1,5 +1,5 @@ import { Component, TemplateRef, computed, inject, signal, viewChild } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; +import { Router } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TableLazyLoadEvent } from 'primeng/table'; import { firstValueFrom } from 'rxjs'; @@ -31,6 +31,22 @@ interface ResearchGroupOption { name: string; } +interface UserRow { + userId?: string; + firstName?: string; + lastName?: string; + email?: string; + avatar?: string; + universityId?: string; + primaryRole?: AdminUserOverviewDTOPrimaryRoleEnum; + researchGroupId?: string; + researchGroupName?: string; + lastActivityAt?: string; + fullName: string; + roleLabelKey: string; + isSelf: boolean; +} + /** * Admin-only page that lists all users (with role + research-group filters, * search, sort and pagination) and lets admins create, import, view, or @@ -43,7 +59,6 @@ interface ResearchGroupOption { ConfirmDialog, DynamicTableComponent, LocalizedDatePipe, - RouterModule, SearchFilterSortBar, TranslateDirective, TranslateModule, @@ -64,7 +79,7 @@ export class ManageUsersPageComponent { sortDirection = signal('DESC'); // ------- Filter state ------- - readonly availableRoleOptions: AdminUserOverviewDTOPrimaryRoleEnum[] = [...AdminUserOverviewDTOPrimaryRoleEnumValues]; + readonly availableRoleOptions: readonly AdminUserOverviewDTOPrimaryRoleEnum[] = AdminUserOverviewDTOPrimaryRoleEnumValues; /** Translation key per role enum, used by the filter-multiselect (translated). */ readonly roleLabelMap: Record = { @@ -90,7 +105,7 @@ export class ManageUsersPageComponent { // ------- Delete dialog state ------- showDeleteDialog = signal(false); - currentUserToDelete = signal(undefined); + currentUserToDelete = signal(undefined); // ------- Computed configs ------- readonly columns = computed(() => { @@ -135,6 +150,25 @@ export class ManageUsersPageComponent { readonly currentUserId = computed(() => this.accountService.loadedUser()?.id ?? ''); + readonly userRows = computed(() => { + const currentId = this.currentUserId(); + return this.users().map(user => ({ + userId: user.userId, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + avatar: user.avatar, + universityId: user.universityId, + primaryRole: user.primaryRole, + researchGroupId: user.researchGroupId, + researchGroupName: user.researchGroupName, + lastActivityAt: user.lastActivityAt, + fullName: `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(), + roleLabelKey: user.primaryRole ? this.roleLabelMap[user.primaryRole] : '', + isSelf: user.userId === currentId, + })); + }); + /** Translation key for the page block; exposed for use in the template. */ readonly translationKey = TRANSLATION_KEY; @@ -150,27 +184,6 @@ export class ManageUsersPageComponent { void this.loadResearchGroups(); } - /** - * Compute a display name for the user-template cell. - * - * @param row the AdminUserOverviewDTO row - * @returns the trimmed `firstName lastName`, or empty string when both are blank - */ - fullName(row: AdminUserOverviewDTO): string { - return `${row.firstName ?? ''} ${row.lastName ?? ''}`.trim(); - } - - /** - * Translation key for a row's role. - * - * @param row the AdminUserOverviewDTO row - * @returns a translation key under `manageUsersPage.roles`, or empty when unset - */ - roleLabelKey(row: AdminUserOverviewDTO): string { - const role = row.primaryRole; - return role ? this.roleLabelMap[role] : ''; - } - async loadPage(event: TableLazyLoadEvent): Promise { const rows = event.rows ?? this.pageSize(); const first = event.first ?? 0; @@ -220,7 +233,7 @@ export class ManageUsersPageComponent { } } - onRequestDelete(row: AdminUserOverviewDTO): void { + onRequestDelete(row: UserRow): void { if (row.userId === this.currentUserId()) { this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.selfDelete`); return; @@ -252,10 +265,6 @@ export class ManageUsersPageComponent { } } - isSelf(row: AdminUserOverviewDTO): boolean { - return row.userId === this.currentUserId(); - } - private async loadUsers(): Promise { this.loading.set(true); try { diff --git a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java deleted file mode 100644 index e4211d34d5..0000000000 --- a/src/test/java/de/tum/cit/aet/usermanagement/service/UserAdminServiceTest.java +++ /dev/null @@ -1,316 +0,0 @@ -package de.tum.cit.aet.usermanagement.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import de.tum.cit.aet.core.dto.PageDTO; -import de.tum.cit.aet.core.dto.SortDTO; -import de.tum.cit.aet.core.exception.EntityNotFoundException; -import de.tum.cit.aet.core.exception.OperationNotAllowedException; -import de.tum.cit.aet.core.retention.UserRetentionService; -import de.tum.cit.aet.core.service.CurrentUserService; -import de.tum.cit.aet.usermanagement.constants.UserRole; -import de.tum.cit.aet.usermanagement.domain.ResearchGroup; -import de.tum.cit.aet.usermanagement.domain.User; -import de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole; -import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; -import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; -import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; -import de.tum.cit.aet.usermanagement.dto.KeycloakUserDTO; -import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; -import de.tum.cit.aet.usermanagement.repository.UserRepository; -import java.time.LocalDate; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; - -@ExtendWith(MockitoExtension.class) -class UserAdminServiceTest { - - @Mock - UserRepository userRepository; - - @Mock - KeycloakUserService keycloakUserService; - - @Mock - UserService userService; - - @Mock - UserRetentionService userRetentionService; - - @Mock - CurrentUserService currentUserService; - - @InjectMocks - UserAdminService userAdminService; - - @Test - void shouldCreateKeycloakUserThenSyncDb() { - UUID newId = UUID.randomUUID(); - when(keycloakUserService.createUserWithPassword(eq("a@b.com"), eq("Alice"), eq("Apple"), eq("hunter2!Long"))).thenReturn(newId); - User user = new User(); - user.setUserId(newId); - when(userService.upsertUser(eq(newId.toString()), eq("a@b.com"), eq("Alice"), eq("Apple"))).thenReturn(user); - when(userRepository.findById(newId)).thenReturn(Optional.of(user)); - - UUID created = userAdminService.create( - new CreateUserDTO("Alice", "Apple", "a@b.com", "hunter2!Long", null, null, null, null, null, null, null, null) - ); - - assertThat(created).isEqualTo(newId); - verify(keycloakUserService).createUserWithPassword("a@b.com", "Alice", "Apple", "hunter2!Long"); - verify(userService).upsertUser(newId.toString(), "a@b.com", "Alice", "Apple"); - } - - @Test - void shouldImportExistingKeycloakUser() { - UUID kcId = UUID.randomUUID(); - when(keycloakUserService.findKeycloakUserById(kcId)).thenReturn( - Optional.of(new KeycloakUserDTO(kcId, "alice", "Alice", "Apple", "a@b.com", null)) - ); - User user = new User(); - user.setUserId(kcId); - when(userService.upsertUser(eq(kcId.toString()), eq("a@b.com"), eq("Alice"), eq("Apple"))).thenReturn(user); - - UUID imported = userAdminService.importFromKeycloak(new ImportUserDTO(kcId)); - - assertThat(imported).isEqualTo(kcId); - } - - @Test - void shouldRejectImportWhenKeycloakUuidMissing() { - UUID kcId = UUID.randomUUID(); - when(keycloakUserService.findKeycloakUserById(kcId)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> userAdminService.importFromKeycloak(new ImportUserDTO(kcId))).isInstanceOf(EntityNotFoundException.class); - } - - @Test - void shouldRejectSelfDelete() { - UUID adminId = UUID.randomUUID(); - when(currentUserService.getUserId()).thenReturn(adminId); - - assertThatThrownBy(() -> userAdminService.delete(adminId)).isInstanceOf(OperationNotAllowedException.class); - verifyNoInteractions(keycloakUserService); - verifyNoInteractions(userRetentionService); - } - - @Test - void shouldDeleteFromKeycloakThenAnonymiseDb() { - UUID adminId = UUID.randomUUID(); - UUID targetId = UUID.randomUUID(); - when(currentUserService.getUserId()).thenReturn(adminId); - - userAdminService.delete(targetId); - - verify(keycloakUserService).deleteUser(targetId); - verify(userRetentionService).deleteUserByAdmin(targetId); - } - - @Test - void shouldDelegateListingToRepositoryWithNormalizedSearchAndNullableFilters() { - var pageDto = new PageDTO(25, 0); - var sortDto = new SortDTO("firstName", SortDTO.Direction.ASC); - Page emptyPage = Page.empty(); - when(userRepository.findAllUsersForAdmin(isNull(), isNull(), anyString(), any())).thenReturn(emptyPage); - - var result = userAdminService.getAllUsersForAdmin(pageDto, sortDto, List.of(), List.of(), " alice "); - - assertThat(result).isSameAs(emptyPage); - } - - @Test - void shouldPassThroughRolesAndResearchGroupsWhenFiltersProvided() { - var pageDto = new PageDTO(25, 0); - var sortDto = new SortDTO("firstName", SortDTO.Direction.ASC); - var roles = List.of(UserRole.PROFESSOR); - var rgIds = List.of(UUID.randomUUID()); - Page emptyPage = Page.empty(); - when(userRepository.findAllUsersForAdmin(eq(roles), eq(rgIds), any(), any())).thenReturn(emptyPage); - - var result = userAdminService.getAllUsersForAdmin(pageDto, sortDto, roles, rgIds, null); - - assertThat(result).isSameAs(emptyPage); - } - - @Test - void shouldReturnDetailWithHighestPriorityRoleAndResearchGroup() { - UUID id = UUID.randomUUID(); - UUID rgId = UUID.randomUUID(); - - var rg = new ResearchGroup(); - rg.setResearchGroupId(rgId); - rg.setName("Test RG"); - - var employeeRole = new UserResearchGroupRole(); - employeeRole.setRole(UserRole.EMPLOYEE); - var professorRole = new UserResearchGroupRole(); - professorRole.setRole(UserRole.PROFESSOR); - - var user = new User(); - user.setUserId(id); - user.setFirstName("Alice"); - user.setLastName("Apple"); - user.setEmail("a@b.com"); - user.setResearchGroup(rg); - user.setResearchGroupRoles(new HashSet<>(Set.of(employeeRole, professorRole))); - - when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(Optional.of(user)); - - var detail = userAdminService.getUserDetail(id); - - assertThat(detail.userId()).isEqualTo(id); - assertThat(detail.firstName()).isEqualTo("Alice"); - assertThat(detail.primaryRole()).isEqualTo(UserRole.PROFESSOR); - assertThat(detail.researchGroupId()).isEqualTo(rgId); - assertThat(detail.researchGroupName()).isEqualTo("Test RG"); - } - - @Test - void shouldReturnDetailWithNullRoleAndResearchGroupWhenAbsent() { - UUID id = UUID.randomUUID(); - var user = new User(); - user.setUserId(id); - user.setFirstName("Bob"); - user.setLastName("Banana"); - user.setEmail("b@b.com"); - when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(Optional.of(user)); - - var detail = userAdminService.getUserDetail(id); - - assertThat(detail.primaryRole()).isNull(); - assertThat(detail.researchGroupId()).isNull(); - assertThat(detail.researchGroupName()).isNull(); - } - - @Test - void shouldThrowWhenUserDetailNotFound() { - UUID id = UUID.randomUUID(); - when(userRepository.findWithResearchGroupRolesByUserId(id)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> userAdminService.getUserDetail(id)).isInstanceOf(EntityNotFoundException.class); - } - - @Test - void shouldUpdateAllFieldsWhenProvided() { - UUID id = UUID.randomUUID(); - var user = new User(); - user.setUserId(id); - when(userRepository.findById(id)).thenReturn(Optional.of(user)); - - var dto = new UpdateUserDTO( - "Alice", - "Apple", - "uni-123", - "+49 0", - "FEMALE", - "DE", - LocalDate.of(1990, 1, 1), - "https://example.com", - "https://linkedin.com/in/alice", - "en", - Boolean.TRUE, - "avatar.png" - ); - - userAdminService.update(id, dto); - - assertThat(user.getFirstName()).isEqualTo("Alice"); - assertThat(user.getLastName()).isEqualTo("Apple"); - assertThat(user.getUniversityId()).isEqualTo("uni-123"); - assertThat(user.getPhoneNumber()).isEqualTo("+49 0"); - assertThat(user.getGender()).isEqualTo("FEMALE"); - assertThat(user.getNationality()).isEqualTo("DE"); - assertThat(user.getBirthday()).isEqualTo(LocalDate.of(1990, 1, 1)); - assertThat(user.getWebsite()).isEqualTo("https://example.com"); - assertThat(user.getLinkedinUrl()).isEqualTo("https://linkedin.com/in/alice"); - assertThat(user.getSelectedLanguage()).isEqualTo("en"); - assertThat(user.isAiFeaturesEnabled()).isTrue(); - assertThat(user.getAvatar()).isEqualTo("avatar.png"); - verify(userRepository).save(user); - } - - @Test - void shouldLeaveFieldsUntouchedWhenUpdateDtoFieldsAreNull() { - UUID id = UUID.randomUUID(); - var user = new User(); - user.setUserId(id); - user.setFirstName("Original"); - user.setLastName("Name"); - when(userRepository.findById(id)).thenReturn(Optional.of(user)); - - userAdminService.update(id, new UpdateUserDTO(null, null, null, null, null, null, null, null, null, null, null, null)); - - assertThat(user.getFirstName()).isEqualTo("Original"); - assertThat(user.getLastName()).isEqualTo("Name"); - verify(userRepository).save(user); - } - - @Test - void shouldThrowWhenUpdateTargetNotFound() { - UUID id = UUID.randomUUID(); - when(userRepository.findById(id)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> - userAdminService.update(id, new UpdateUserDTO("A", null, null, null, null, null, null, null, null, null, null, null)) - ).isInstanceOf(EntityNotFoundException.class); - } - - @Test - void shouldApplyOptionalCreateFieldsAndSaveWhenAnyProvided() { - UUID newId = UUID.randomUUID(); - var user = new User(); - user.setUserId(newId); - when( - keycloakUserService.createUserWithPassword(eq("a@b.com"), eq("Alice"), eq("Apple"), eq("hunter2!Long")) - ).thenReturn(newId); - when( - userService.upsertUser(eq(newId.toString()), eq("a@b.com"), eq("Alice"), eq("Apple")) - ).thenReturn(user); - when(userRepository.findById(newId)).thenReturn(Optional.of(user)); - - var dto = new CreateUserDTO( - "Alice", - "Apple", - "a@b.com", - "hunter2!Long", - "uni-1", - "+49", - "FEMALE", - "DE", - LocalDate.of(1995, 5, 5), - "https://example.com", - "https://linkedin.com/in/a", - "en" - ); - - var created = userAdminService.create(dto); - - assertThat(created).isEqualTo(newId); - assertThat(user.getUniversityId()).isEqualTo("uni-1"); - assertThat(user.getPhoneNumber()).isEqualTo("+49"); - assertThat(user.getGender()).isEqualTo("FEMALE"); - assertThat(user.getNationality()).isEqualTo("DE"); - assertThat(user.getBirthday()).isEqualTo(LocalDate.of(1995, 5, 5)); - assertThat(user.getWebsite()).isEqualTo("https://example.com"); - assertThat(user.getLinkedinUrl()).isEqualTo("https://linkedin.com/in/a"); - assertThat(user.getSelectedLanguage()).isEqualTo("en"); - verify(userRepository).save(user); - } -} diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts index 4536a1cf59..8730c6b4e9 100644 --- a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts +++ b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts @@ -1,39 +1,26 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ManageUserFormComponent } from 'app/usermanagement/manage-users/manage-user-form.component'; -import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; import { AdminUserDetailDTO } from 'app/generated/model/admin-user-detail-dto'; import { provideToastServiceMock, createToastServiceMock, ToastServiceMock } from 'util/toast-service.mock'; import { provideTranslateMock, createTranslateServiceMock } from 'util/translate.mock'; import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; +import { provideRouterMock, createRouterMock, RouterMock } from 'util/router.mock'; +import { provideActivatedRouteMock, createActivatedRouteMock } from 'util/activated-route.mock'; +import { provideLocationMock } from 'util/location.mock'; import { createUserAdminResourceApiMock, provideUserAdminResourceApiMock, UserAdminResourceApiMock, } from 'util/user-admin-resource-api.service.mock'; -/** - * Build a snapshot-only ActivatedRoute mock for a given set of route params and - * query params. The component only reads `snapshot.paramMap.get` and - * `snapshot.queryParamMap.get`, so a minimal stub is enough. - */ -function makeRoute(params: Record = {}, query: Record = {}): unknown { - return { - snapshot: { - paramMap: { get: (key: string) => params[key] ?? null }, - queryParamMap: { get: (key: string) => query[key] ?? null }, - }, - }; -} - describe('ManageUserFormComponent', () => { let mockUserAdminApi: UserAdminResourceApiMock; let mockToastService: ToastServiceMock; - let mockRouter: { navigate: ReturnType }; + let mockRouter: RouterMock; const loadedUser: AdminUserDetailDTO = { userId: 'user-1', @@ -50,10 +37,6 @@ describe('ManageUserFormComponent', () => { birthday: '1990-01-01', }; - /** - * Configure the TestBed with the requested route params/query params and - * return the resulting fixture + component. - */ async function setupComponent( params: Record = {}, query: Record = {}, @@ -63,11 +46,11 @@ describe('ManageUserFormComponent', () => { mockUserAdminApi.getUser.mockReturnValue(of(loadedUser)); mockUserAdminApi.createUser.mockReturnValue(of({ userId: 'new-user-1' } as AdminUserDetailDTO)); mockUserAdminApi.importUser.mockReturnValue(of({ userId: 'new-user-2' } as AdminUserDetailDTO)); - mockUserAdminApi.updateUser.mockReturnValue(of({ ...loadedUser, firstName: 'Alicia' })); + mockUserAdminApi.updateUser.mockReturnValue(of({ userId: 'user-1', firstName: 'Alicia' } as AdminUserDetailDTO)); mockUserAdminApi.deleteUser.mockReturnValue(of(undefined)); mockToastService = createToastServiceMock(); - mockRouter = { navigate: vi.fn().mockResolvedValue(true) }; + mockRouter = createRouterMock(); await TestBed.configureTestingModule({ imports: [ManageUserFormComponent], @@ -76,15 +59,14 @@ describe('ManageUserFormComponent', () => { provideToastServiceMock(mockToastService), provideTranslateMock(createTranslateServiceMock()), provideFontAwesomeTesting(), - { provide: Router, useValue: mockRouter }, - { provide: ActivatedRoute, useValue: makeRoute(params, query) }, + provideRouterMock(mockRouter), + provideActivatedRouteMock(createActivatedRouteMock(params, query)), + provideLocationMock(), ], }).compileComponents(); const fixture = TestBed.createComponent(ManageUserFormComponent); - // Run the constructor `effect` so mode + validators get applied. fixture.detectChanges(); - // Flush any pending microtasks for getUser in edit mode. await Promise.resolve(); await Promise.resolve(); return fixture; @@ -120,7 +102,6 @@ describe('ManageUserFormComponent', () => { expect(component.loadedUser()).toEqual(loadedUser); expect(component.form.controls.firstName.value).toBe('Alice'); expect(component.form.controls.lastName.value).toBe('Anderson'); - // Email control is disabled in edit mode but its raw value is the loaded email. expect(component.form.getRawValue().email).toBe('alice@example.com'); expect(component.form.controls.email.disabled).toBe(true); expect(component.title()).toBe('manageUsersPage.form.editTitle'); @@ -132,7 +113,7 @@ describe('ManageUserFormComponent', () => { mockUserAdminApi = createUserAdminResourceApiMock(); mockUserAdminApi.getUser.mockReturnValue(throwError(() => new Error('not found'))); mockToastService = createToastServiceMock(); - mockRouter = { navigate: vi.fn().mockResolvedValue(true) }; + mockRouter = createRouterMock(); await TestBed.configureTestingModule({ imports: [ManageUserFormComponent], @@ -141,8 +122,9 @@ describe('ManageUserFormComponent', () => { provideToastServiceMock(mockToastService), provideTranslateMock(createTranslateServiceMock()), provideFontAwesomeTesting(), - { provide: Router, useValue: mockRouter }, - { provide: ActivatedRoute, useValue: makeRoute({ userId: 'user-1' }) }, + provideRouterMock(mockRouter), + provideActivatedRouteMock(createActivatedRouteMock({ userId: 'user-1' })), + provideLocationMock(), ], }).compileComponents(); @@ -166,7 +148,6 @@ describe('ManageUserFormComponent', () => { expect(form.controls.email.hasError('required')).toBe(true); expect(form.controls.password.hasError('required')).toBe(true); - // Password complexity: must have lowercase, uppercase, digit, length >= 8. form.controls.password.setValue('short1A'); expect(form.controls.password.hasError('pattern')).toBe(true); form.controls.password.setValue('LongEnough1'); @@ -190,7 +171,6 @@ describe('ManageUserFormComponent', () => { const fixture = await setupComponent({ userId: 'user-1' }); const form = fixture.componentInstance.form; - // Identity fields still required in edit mode. form.patchValue({ firstName: '', lastName: '', password: '', keycloakUserId: '' }); expect(form.controls.firstName.hasError('required')).toBe(true); expect(form.controls.lastName.hasError('required')).toBe(true); @@ -213,7 +193,7 @@ describe('ManageUserFormComponent', () => { await component.onSubmit(); - expect(mockUserAdminApi.createUser).toHaveBeenCalledTimes(1); + expect(mockUserAdminApi.createUser).toHaveBeenCalledOnce(); const dto = mockUserAdminApi.createUser.mock.calls[0][0]; expect(dto.firstName).toBe('Carol'); expect(dto.lastName).toBe('Carter'); @@ -244,7 +224,7 @@ describe('ManageUserFormComponent', () => { await component.onSubmit(); - expect(mockUserAdminApi.updateUser).toHaveBeenCalledTimes(1); + expect(mockUserAdminApi.updateUser).toHaveBeenCalledOnce(); const [calledUserId, dto] = mockUserAdminApi.updateUser.mock.calls[0]; expect(calledUserId).toBe('user-1'); expect(dto.firstName).toBe('Alicia'); @@ -257,7 +237,6 @@ describe('ManageUserFormComponent', () => { const fixture = await setupComponent(); const component = fixture.componentInstance; - // Empty form is invalid in create mode. await component.onSubmit(); expect(mockUserAdminApi.createUser).not.toHaveBeenCalled(); @@ -343,7 +322,6 @@ describe('ManageUserFormComponent', () => { const component = fixture.componentInstance; mockUserAdminApi.deleteUser.mockReturnValueOnce(throwError(() => new Error('forbidden'))); - // Stop the success path navigation from the earlier setup. mockRouter.navigate.mockClear(); await component.onConfirmDelete(); @@ -362,14 +340,6 @@ describe('ManageUserFormComponent', () => { }); }); - describe('Navigation', () => { - it('should navigate back to the list', async () => { - const fixture = await setupComponent(); - fixture.componentInstance.onBack(); - expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users']); - }); - }); - describe('Select option helpers', () => { it('should keep the selected gender, nationality, language, and birthday signals in sync', async () => { const fixture = await setupComponent(); diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts index fa2848f8a6..2f579df875 100644 --- a/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts +++ b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts @@ -1,19 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; import { TableLazyLoadEvent } from 'primeng/table'; import { of, throwError } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ManageUsersPageComponent } from 'app/usermanagement/manage-users/manage-users-page.component'; import { AccountService } from 'app/core/auth/account.service'; -import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; -import { ResearchGroupResourceApi } from 'app/generated/api/research-group-resource-api'; import { AdminUserOverviewDTO } from 'app/generated/model/admin-user-overview-dto'; import { FilterChange } from 'app/shared/components/atoms/filter-multiselect/filter-multiselect'; import { provideToastServiceMock, createToastServiceMock, ToastServiceMock } from 'util/toast-service.mock'; import { provideTranslateMock, createTranslateServiceMock } from 'util/translate.mock'; import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; +import { provideRouterMock, createRouterMock, RouterMock } from 'util/router.mock'; +import { provideActivatedRouteMock, createActivatedRouteMock } from 'util/activated-route.mock'; import { createUserAdminResourceApiMock, provideUserAdminResourceApiMock, @@ -31,7 +30,7 @@ describe('ManageUsersPageComponent', () => { let mockUserAdminApi: UserAdminResourceApiMock; let mockResearchGroupApi: ResearchGroupResourceApiMock; let mockToastService: ToastServiceMock; - let mockRouter: { navigate: ReturnType }; + let mockRouter: RouterMock; let mockAccountService: { loadedUser: ReturnType }; const userAlice: AdminUserOverviewDTO = { @@ -71,7 +70,7 @@ describe('ManageUsersPageComponent', () => { mockResearchGroupApi.getResearchGroupsForAdmin.mockReturnValue(of(defaultResearchGroupPage)); mockToastService = createToastServiceMock(); - mockRouter = { navigate: vi.fn().mockResolvedValue(true) }; + mockRouter = createRouterMock(); mockAccountService = { loadedUser: vi.fn().mockReturnValue({ id: 'admin-id', name: 'Bob Burton' }), }; @@ -84,7 +83,8 @@ describe('ManageUsersPageComponent', () => { provideToastServiceMock(mockToastService), provideTranslateMock(createTranslateServiceMock()), provideFontAwesomeTesting(), - { provide: Router, useValue: mockRouter }, + provideRouterMock(mockRouter), + provideActivatedRouteMock(createActivatedRouteMock()), { provide: AccountService, useValue: mockAccountService }, ], }).compileComponents(); @@ -98,10 +98,6 @@ describe('ManageUsersPageComponent', () => { }); describe('Initialization', () => { - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should load research group options on init', async () => { await Promise.resolve(); await Promise.resolve(); @@ -192,7 +188,6 @@ describe('ManageUsersPageComponent', () => { }); it('should map research-group names to ids on filter change', async () => { - // Wait for research group load to complete first. await Promise.resolve(); await Promise.resolve(); mockUserAdminApi.getAllUsers.mockClear(); @@ -255,29 +250,32 @@ describe('ManageUsersPageComponent', () => { expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users/create'], { queryParams: { mode: 'import' } }); }); - it('should request delete and open dialog for non-self row', () => { - component.onRequestDelete(userAlice); + it('should request delete and open dialog for non-self row', async () => { + await component.loadPage({ first: 0, rows: 10 } as TableLazyLoadEvent); + const aliceRow = component.userRows().find(row => row.userId === 'user-1')!; + + component.onRequestDelete(aliceRow); - expect(component.currentUserToDelete()).toBe(userAlice); + expect(component.currentUserToDelete()).toBe(aliceRow); expect(component.showDeleteDialog()).toBe(true); expect(mockToastService.showErrorKey).not.toHaveBeenCalled(); }); - it('should disable delete on the current admin own row by toasting and skipping the dialog', () => { - component.onRequestDelete(userBob); + it('should disable delete on the current admin own row by toasting and skipping the dialog', async () => { + await component.loadPage({ first: 0, rows: 10 } as TableLazyLoadEvent); + const bobRow = component.userRows().find(row => row.userId === 'admin-id')!; + + component.onRequestDelete(bobRow); expect(component.currentUserToDelete()).toBeUndefined(); expect(component.showDeleteDialog()).toBe(false); expect(mockToastService.showErrorKey).toHaveBeenCalledWith('manageUsersPage.errors.selfDelete'); }); - it('should treat the current admin row as self', () => { - expect(component.isSelf(userBob)).toBe(true); - expect(component.isSelf(userAlice)).toBe(false); - }); - it('should call deleteUser on confirmed delete', async () => { - component.currentUserToDelete.set(userAlice); + await component.loadPage({ first: 0, rows: 10 } as TableLazyLoadEvent); + const aliceRow = component.userRows().find(row => row.userId === 'user-1')!; + component.currentUserToDelete.set(aliceRow); await component.onConfirmDelete(); @@ -295,8 +293,10 @@ describe('ManageUsersPageComponent', () => { }); it('should toast error when delete fails', async () => { + await component.loadPage({ first: 0, rows: 10 } as TableLazyLoadEvent); + const aliceRow = component.userRows().find(row => row.userId === 'user-1')!; mockUserAdminApi.deleteUser.mockReturnValueOnce(throwError(() => new Error('forbidden'))); - component.currentUserToDelete.set(userAlice); + component.currentUserToDelete.set(aliceRow); await component.onConfirmDelete(); @@ -319,15 +319,20 @@ describe('ManageUsersPageComponent', () => { }); }); - describe('Display helpers', () => { - it('should compute fullName from first and last name', () => { - expect(component.fullName(userAlice)).toBe('Alice Anderson'); - expect(component.fullName({ userId: 'x' } as AdminUserOverviewDTO)).toBe(''); - }); + describe('Row enrichment', () => { + it('should compute fullName, roleLabelKey, and isSelf for each loaded row', async () => { + await component.loadPage({ first: 0, rows: 10 } as TableLazyLoadEvent); + const rows = component.userRows(); + + const alice = rows.find(row => row.userId === 'user-1')!; + expect(alice.fullName).toBe('Alice Anderson'); + expect(alice.roleLabelKey).toBe('manageUsersPage.roles.PROFESSOR'); + expect(alice.isSelf).toBe(false); - it('should map the row role to the matching translation key', () => { - expect(component.roleLabelKey(userAlice)).toBe('manageUsersPage.roles.PROFESSOR'); - expect(component.roleLabelKey({ userId: 'x' } as AdminUserOverviewDTO)).toBe(''); + const bob = rows.find(row => row.userId === 'admin-id')!; + expect(bob.fullName).toBe('Bob Burton'); + expect(bob.roleLabelKey).toBe('manageUsersPage.roles.ADMIN'); + expect(bob.isSelf).toBe(true); }); }); }); From e601ad7e9b70fed625bd8e4e959782f8496f835a Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Thu, 21 May 2026 18:02:41 +0200 Subject: [PATCH 20/28] \`General\`: Align Manage Users form header with project page-header pattern Co-Authored-By: Claude Opus 4.7 (1M context) --- .../manage-users/manage-user-form.component.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html index 2bd054857a..b88d1cc52a 100644 --- a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html @@ -11,10 +11,12 @@ (closed)="showDeleteDialog.set(false)" /> -
-
- -

+
+ @if (mode() === 'edit') { Date: Thu, 21 May 2026 18:06:22 +0200 Subject: [PATCH 21/28] `Test`: Use account service util mock and tighten toast assertions Co-Authored-By: Claude Opus 4.7 (1M context) --- .../manage-user-form.component.spec.ts | 16 ++++++++-------- .../manage-users-page.component.spec.ts | 15 +++++++-------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts index 8730c6b4e9..87b595f22c 100644 --- a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts +++ b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts @@ -200,7 +200,7 @@ describe('ManageUserFormComponent', () => { expect(dto.email).toBe('carol@example.com'); expect(dto.password).toBe('StrongPass1'); expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users', 'new-user-1']); - expect(mockToastService.showSuccess).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledOnce(); }); it('should call importUser in import mode and navigate to the new user page', async () => { @@ -213,7 +213,7 @@ describe('ManageUserFormComponent', () => { expect(mockUserAdminApi.importUser).toHaveBeenCalledWith({ keycloakUserId: 'kc-id-123' }); expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users', 'new-user-2']); - expect(mockToastService.showSuccess).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledOnce(); }); it('should call updateUser in edit mode and stay on the page', async () => { @@ -230,7 +230,7 @@ describe('ManageUserFormComponent', () => { expect(dto.firstName).toBe('Alicia'); expect(mockRouter.navigate).not.toHaveBeenCalled(); expect(component.loadedUser()?.firstName).toBe('Alicia'); - expect(mockToastService.showSuccess).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledOnce(); }); it('should mark form as touched and skip the API call when invalid', async () => { @@ -257,7 +257,7 @@ describe('ManageUserFormComponent', () => { await component.onSubmit(); - expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockToastService.showError).toHaveBeenCalledOnce(); expect(mockRouter.navigate).not.toHaveBeenCalled(); }); @@ -270,7 +270,7 @@ describe('ManageUserFormComponent', () => { await component.onSubmit(); - expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockToastService.showError).toHaveBeenCalledOnce(); expect(mockRouter.navigate).not.toHaveBeenCalled(); }); @@ -283,7 +283,7 @@ describe('ManageUserFormComponent', () => { await component.onSubmit(); - expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockToastService.showError).toHaveBeenCalledOnce(); }); }); @@ -314,7 +314,7 @@ describe('ManageUserFormComponent', () => { expect(mockUserAdminApi.deleteUser).toHaveBeenCalledWith('user-1'); expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users']); - expect(mockToastService.showSuccess).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledOnce(); }); it('should toast error when delete fails', async () => { @@ -326,7 +326,7 @@ describe('ManageUserFormComponent', () => { await component.onConfirmDelete(); - expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockToastService.showError).toHaveBeenCalledOnce(); expect(mockRouter.navigate).not.toHaveBeenCalledWith(['/manage-users']); }); diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts index 2f579df875..edadd97c9a 100644 --- a/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts +++ b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts @@ -4,7 +4,6 @@ import { of, throwError } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ManageUsersPageComponent } from 'app/usermanagement/manage-users/manage-users-page.component'; -import { AccountService } from 'app/core/auth/account.service'; import { AdminUserOverviewDTO } from 'app/generated/model/admin-user-overview-dto'; import { FilterChange } from 'app/shared/components/atoms/filter-multiselect/filter-multiselect'; @@ -13,6 +12,7 @@ import { provideTranslateMock, createTranslateServiceMock } from 'util/translate import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; import { provideRouterMock, createRouterMock, RouterMock } from 'util/router.mock'; import { provideActivatedRouteMock, createActivatedRouteMock } from 'util/activated-route.mock'; +import { provideAccountServiceMock, createAccountServiceMock, AccountServiceMock } from 'util/account.service.mock'; import { createUserAdminResourceApiMock, provideUserAdminResourceApiMock, @@ -31,7 +31,7 @@ describe('ManageUsersPageComponent', () => { let mockResearchGroupApi: ResearchGroupResourceApiMock; let mockToastService: ToastServiceMock; let mockRouter: RouterMock; - let mockAccountService: { loadedUser: ReturnType }; + let mockAccountService: AccountServiceMock; const userAlice: AdminUserOverviewDTO = { userId: 'user-1', @@ -71,9 +71,8 @@ describe('ManageUsersPageComponent', () => { mockToastService = createToastServiceMock(); mockRouter = createRouterMock(); - mockAccountService = { - loadedUser: vi.fn().mockReturnValue({ id: 'admin-id', name: 'Bob Burton' }), - }; + mockAccountService = createAccountServiceMock(); + mockAccountService.user.set({ id: 'admin-id', name: 'Bob Burton', email: 'bob@example.com', authorities: [] }); await TestBed.configureTestingModule({ imports: [ManageUsersPageComponent], @@ -85,7 +84,7 @@ describe('ManageUsersPageComponent', () => { provideFontAwesomeTesting(), provideRouterMock(mockRouter), provideActivatedRouteMock(createActivatedRouteMock()), - { provide: AccountService, useValue: mockAccountService }, + provideAccountServiceMock(mockAccountService), ], }).compileComponents(); @@ -280,7 +279,7 @@ describe('ManageUsersPageComponent', () => { await component.onConfirmDelete(); expect(mockUserAdminApi.deleteUser).toHaveBeenCalledWith('user-1'); - expect(mockToastService.showSuccess).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledOnce(); expect(component.currentUserToDelete()).toBeUndefined(); }); @@ -300,7 +299,7 @@ describe('ManageUsersPageComponent', () => { await component.onConfirmDelete(); - expect(mockToastService.showError).toHaveBeenCalled(); + expect(mockToastService.showError).toHaveBeenCalledOnce(); expect(component.currentUserToDelete()).toBeUndefined(); }); }); From ecbced22af1fa836a6d1f7a608f62a9e8aaca0d3 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Thu, 21 May 2026 18:25:42 +0200 Subject: [PATCH 22/28] `Development`: Let admins set a user's role and add language labels Adds Role + Research Group selection to the create/edit user form (group is required for Employee/Professor, hidden otherwise) and ships the missing languages.en/de translation keys. The server side replaces existing role mappings via UserAdminService and prevents admins from changing their own role. Co-Authored-By: Claude Opus 4.7 (1M context) --- openapi/openapi.yaml | 326 ++++++++++++++++++ .../aet/usermanagement/dto/CreateUserDTO.java | 6 +- .../aet/usermanagement/dto/UpdateUserDTO.java | 6 +- .../service/UserAdminService.java | 71 ++++ .../app/generated/.openapi-generator/FILES | 3 + .../app/generated/model/create-user-dto.ts | 14 + .../app/generated/model/update-user-dto.ts | 14 + .../manage-user-form.component.html | 35 +- .../manage-user-form.component.ts | 88 ++++- src/main/webapp/i18n/de/languages.json | 6 + src/main/webapp/i18n/de/usermanagement.json | 3 + src/main/webapp/i18n/en/languages.json | 6 + src/main/webapp/i18n/en/usermanagement.json | 3 + .../manage-user-form.component.spec.ts | 21 ++ 14 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 src/main/webapp/i18n/de/languages.json create mode 100644 src/main/webapp/i18n/en/languages.json diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 171461cbc9..7eb89efd4b 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2,6 +2,10 @@ openapi: 3.1.0 info: {title: OpenAPI definition, version: v0} servers: - {url: 'http://localhost:8080', description: Generated server url} +tags: +- name: Actuator + description: Monitor and interact + externalDocs: {description: Spring Boot Actuator Web API Documentation, url: 'https://docs.spring.io/spring-boot/docs/current/actuator-api/html/'} paths: /api/admin/dependencies: get: @@ -2868,6 +2872,315 @@ paths: required: true responses: '200': {description: OK} + /management: + get: + tags: [Actuator] + summary: Actuator root web endpoint + operationId: links + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: + type: object + additionalProperties: + type: object + additionalProperties: {$ref: '#/components/schemas/Link'} + application/vnd.spring-boot.actuator.v2+json: + schema: + type: object + additionalProperties: + type: object + additionalProperties: {$ref: '#/components/schemas/Link'} + application/json: + schema: + type: object + additionalProperties: + type: object + additionalProperties: {$ref: '#/components/schemas/Link'} + /management/caches: + get: + tags: [Actuator] + summary: Actuator web endpoint 'caches' + operationId: caches + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + delete: + tags: [Actuator] + summary: Actuator web endpoint 'caches' + operationId: clearCaches + responses: + '204': {description: No Content} + /management/caches/{cache}: + get: + tags: [Actuator] + summary: Actuator web endpoint 'caches-cache' + operationId: cache + parameters: + - name: cache + in: path + required: true + schema: {type: string} + - name: cacheManager + in: query + schema: {type: string} + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + '404': {description: Not Found} + delete: + tags: [Actuator] + summary: Actuator web endpoint 'caches-cache' + operationId: clearCache + parameters: + - name: cache + in: path + required: true + schema: {type: string} + - name: cacheManager + in: query + schema: {type: string} + responses: + '204': {description: No Content} + '404': {description: Not Found} + /management/configprops: + get: + tags: [Actuator] + summary: Actuator web endpoint 'configprops' + operationId: configurationProperties + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/configprops/{prefix}: + get: + tags: [Actuator] + summary: Actuator web endpoint 'configprops-prefix' + operationId: configurationPropertiesWithPrefix + parameters: + - name: prefix + in: path + required: true + schema: {type: string} + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + '404': {description: Not Found} + /management/env: + get: + tags: [Actuator] + summary: Actuator web endpoint 'env' + operationId: environment + parameters: + - name: pattern + in: query + schema: {type: string} + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/env/{toMatch}: + get: + tags: [Actuator] + summary: Actuator web endpoint 'env-toMatch' + operationId: environmentEntry + parameters: + - name: toMatch + in: path + required: true + schema: {type: string} + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + '404': {description: Not Found} + /management/health: + get: + tags: [Actuator] + summary: Actuator web endpoint 'health' + operationId: health + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/info: + get: + tags: [Actuator] + summary: Actuator web endpoint 'info' + operationId: info + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/jhimetrics: + get: + tags: [Actuator] + summary: Actuator web endpoint 'jhimetrics' + operationId: allMetrics + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/jhiopenapigroups: + get: + tags: [Actuator] + summary: Actuator web endpoint 'jhiopenapigroups' + operationId: allOpenApi + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/liquibase: + get: + tags: [Actuator] + summary: Actuator web endpoint 'liquibase' + operationId: liquibaseBeans + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/loggers: + get: + tags: [Actuator] + summary: Actuator web endpoint 'loggers' + operationId: loggers + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + /management/loggers/{name}: + get: + tags: [Actuator] + summary: Actuator web endpoint 'loggers-name' + operationId: loggerLevels + parameters: + - name: name + in: path + required: true + schema: {type: string} + responses: + '200': + description: OK + content: + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} + '404': {description: Not Found} + post: + tags: [Actuator] + summary: Actuator web endpoint 'loggers-name' + operationId: configureLogLevel + parameters: + - name: name + in: path + required: true + schema: {type: string} + requestBody: + content: + application/json: + schema: + type: string + enum: [TRACE, DEBUG, INFO, WARN, ERROR, FATAL, 'OFF'] + responses: + '204': {description: No Content} + '400': {description: Bad Request} + /management/threaddump: + get: + tags: [Actuator] + summary: Actuator web endpoint 'threaddump' + operationId: threadDump + responses: + '200': + description: OK + content: + text/plain;charset=UTF-8: + schema: {type: object} + application/vnd.spring-boot.actuator.v3+json: + schema: {type: object} + application/vnd.spring-boot.actuator.v2+json: + schema: {type: object} + application/json: + schema: {type: object} components: schemas: AcceptDTO: @@ -3226,6 +3539,10 @@ components: nationality: {type: string} password: {type: string, maxLength: 2147483647, minLength: 8} phoneNumber: {type: string} + primaryRole: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] + researchGroupId: {type: string, format: uuid} selectedLanguage: {type: string} universityId: {type: string} website: {type: string} @@ -3748,6 +4065,11 @@ components: lastName: {type: string} universityId: {type: string} username: {type: string} + Link: + type: object + properties: + href: {type: string} + templated: {type: boolean} LoginRequestDTO: type: object properties: @@ -4169,6 +4491,10 @@ components: linkedinUrl: {type: string} nationality: {type: string} phoneNumber: {type: string} + primaryRole: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] + researchGroupId: {type: string, format: uuid} selectedLanguage: {type: string} universityId: {type: string} website: {type: string} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java index 9f4b8e4f03..0e19965f38 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java @@ -1,10 +1,12 @@ package de.tum.cit.aet.usermanagement.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.usermanagement.constants.UserRole; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.time.LocalDate; +import java.util.UUID; /** * Request body for admin user creation. Required fields are sent to Keycloak; @@ -23,5 +25,7 @@ public record CreateUserDTO( LocalDate birthday, String website, String linkedinUrl, - String selectedLanguage + String selectedLanguage, + UserRole primaryRole, + UUID researchGroupId ) {} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java index 1bb4cf583e..54e4aca118 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java @@ -1,7 +1,9 @@ package de.tum.cit.aet.usermanagement.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.usermanagement.constants.UserRole; import java.time.LocalDate; +import java.util.UUID; /** * Request body for admin user edits. All fields optional. `email`, `password`, @@ -20,5 +22,7 @@ public record UpdateUserDTO( String linkedinUrl, String selectedLanguage, Boolean aiFeaturesEnabled, - String avatar + String avatar, + UserRole primaryRole, + UUID researchGroupId ) {} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java index 568493bbef..4c51b8939d 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java @@ -3,20 +3,25 @@ import de.tum.cit.aet.core.dto.PageDTO; import de.tum.cit.aet.core.dto.SortDTO; import de.tum.cit.aet.core.exception.EntityNotFoundException; +import de.tum.cit.aet.core.exception.InvalidParameterException; import de.tum.cit.aet.core.exception.OperationNotAllowedException; import de.tum.cit.aet.core.retention.UserRetentionService; import de.tum.cit.aet.core.service.CurrentUserService; import de.tum.cit.aet.core.util.PageUtil; import de.tum.cit.aet.core.util.StringUtil; import de.tum.cit.aet.usermanagement.constants.UserRole; +import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.User; +import de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole; import de.tum.cit.aet.usermanagement.dto.AdminUserDetailDTO; import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; import de.tum.cit.aet.usermanagement.dto.KeycloakUserDTO; import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; +import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; import de.tum.cit.aet.usermanagement.repository.UserRepository; +import de.tum.cit.aet.usermanagement.repository.UserResearchGroupRoleRepository; import java.util.Comparator; import java.util.List; import java.util.UUID; @@ -24,6 +29,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** * Orchestrates Keycloak and local-DB user management for admins. @@ -35,6 +41,8 @@ public class UserAdminService { private final UserRepository userRepository; + private final UserResearchGroupRoleRepository userResearchGroupRoleRepository; + private final ResearchGroupRepository researchGroupRepository; private final KeycloakUserService keycloakUserService; private final UserService userService; private final UserRetentionService userRetentionService; @@ -119,13 +127,19 @@ public AdminUserDetailDTO getUserDetail(UUID userId) { * @param dto the create-user payload * @return the new user's UUID */ + @Transactional public UUID create(CreateUserDTO dto) { + validateRoleAndGroup(dto.primaryRole(), dto.researchGroupId()); // 1) Keycloak first — failure aborts before any DB write. UUID userId = keycloakUserService.createUserWithPassword(dto.email(), dto.firstName(), dto.lastName(), dto.password()); // 2) Local DB upsert (creates user + assigns APPLICANT role if missing). userService.upsertUser(userId.toString(), dto.email(), dto.firstName(), dto.lastName()); // 3) Apply DB-only optional fields. applyOptionalCreateFields(userId, dto); + // 4) Apply primary role assignment when supplied. + if (dto.primaryRole() != null) { + applyPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); + } return userId; } @@ -152,7 +166,12 @@ public UUID importFromKeycloak(ImportUserDTO dto) { * @param dto the update payload (any null field is left untouched) * @throws EntityNotFoundException if no user exists with the given ID */ + @Transactional public void update(UUID userId, UpdateUserDTO dto) { + validateRoleAndGroup(dto.primaryRole(), dto.researchGroupId()); + if (dto.primaryRole() != null && userId.equals(currentUserService.getUserId())) { + throw new OperationNotAllowedException("Admins cannot change their own role."); + } User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); if (dto.firstName() != null) { user.setFirstName(dto.firstName()); @@ -191,6 +210,9 @@ public void update(UUID userId, UpdateUserDTO dto) { user.setAvatar(dto.avatar()); } userRepository.save(user); + if (dto.primaryRole() != null) { + applyPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); + } } /** @@ -256,6 +278,55 @@ private void applyOptionalCreateFields(UUID userId, CreateUserDTO dto) { } } + /** + * Validates that the role/group combination is consistent: EMPLOYEE and PROFESSOR + * require a research group; APPLICANT and ADMIN must be supplied without one. + * + * @param role the requested primary role (may be null) + * @param researchGroupId the requested research group (may be null) + */ + private void validateRoleAndGroup(UserRole role, UUID researchGroupId) { + if (role == null) { + return; + } + boolean groupBound = role == UserRole.EMPLOYEE || role == UserRole.PROFESSOR; + if (groupBound && researchGroupId == null) { + throw new InvalidParameterException("Role " + role + " requires a researchGroupId."); + } + if (!groupBound && researchGroupId != null) { + throw new InvalidParameterException("Role " + role + " must not be assigned to a research group."); + } + } + + /** + * Replaces all existing role mappings of a user with a single mapping that matches + * the requested role. EMPLOYEE/PROFESSOR roles are attached to the supplied research + * group; APPLICANT and ADMIN are attached to no group. The user's primary research + * group on the {@link User} entity is kept in sync with the new mapping. + * + * @param userId the user being updated + * @param role the new primary role + * @param researchGroupId the research group for EMPLOYEE/PROFESSOR, otherwise null + */ + private void applyPrimaryRole(UUID userId, UserRole role, UUID researchGroupId) { + User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); + ResearchGroup group = researchGroupId == null ? null : researchGroupRepository.findByIdElseThrow(researchGroupId); + + // 1) Drop existing role mappings — admin-driven role swap is a hard replace. + userResearchGroupRoleRepository.deleteByUserId(userId); + + // 2) Create the new role mapping. + UserResearchGroupRole mapping = new UserResearchGroupRole(); + mapping.setUser(user); + mapping.setRole(role); + mapping.setResearchGroup(group); + userResearchGroupRoleRepository.save(mapping); + + // 3) Keep the primary research-group reference on the user aligned with the new role. + user.setResearchGroup(group); + userRepository.save(user); + } + /** * Ranks roles so the highest-privilege role is selected as the user's primary role. * diff --git a/src/main/webapp/app/generated/.openapi-generator/FILES b/src/main/webapp/app/generated/.openapi-generator/FILES index 49e7fb62b1..a1587e7ad1 100644 --- a/src/main/webapp/app/generated/.openapi-generator/FILES +++ b/src/main/webapp/app/generated/.openapi-generator/FILES @@ -1,3 +1,5 @@ +api/actuator-api.ts +api/actuator-resources.ts api/admin-dependency-resource-api.ts api/admin-dependency-resource-resources.ts api/admin-export-resource-api.ts @@ -121,6 +123,7 @@ model/job-form-dto.ts model/job-preview-request.ts model/keycloak-config.ts model/keycloak-user-dto.ts +model/link.ts model/login-request-dto.ts model/otp-complete-dto.ts model/otp-config.ts diff --git a/src/main/webapp/app/generated/model/create-user-dto.ts b/src/main/webapp/app/generated/model/create-user-dto.ts index 09b4b7771f..ea8ea2667b 100644 --- a/src/main/webapp/app/generated/model/create-user-dto.ts +++ b/src/main/webapp/app/generated/model/create-user-dto.ts @@ -19,7 +19,21 @@ export interface CreateUserDTO { readonly nationality?: string; readonly password: string; readonly phoneNumber?: string; + readonly primaryRole?: CreateUserDTOPrimaryRoleEnum; + readonly researchGroupId?: string; readonly selectedLanguage?: string; readonly universityId?: string; readonly website?: string; } + +export type CreateUserDTOPrimaryRoleEnum = 'APPLICANT' | 'PROFESSOR' | 'ADMIN' | 'EMPLOYEE'; + +export const CreateUserDTOPrimaryRoleEnum = { + Applicant: 'APPLICANT' as const, + Professor: 'PROFESSOR' as const, + Admin: 'ADMIN' as const, + Employee: 'EMPLOYEE' as const, +} as const; + +export const CreateUserDTOPrimaryRoleEnumValues = ['APPLICANT', 'PROFESSOR', 'ADMIN', 'EMPLOYEE'] as const; + diff --git a/src/main/webapp/app/generated/model/update-user-dto.ts b/src/main/webapp/app/generated/model/update-user-dto.ts index 8ca8a09170..d97ed144de 100644 --- a/src/main/webapp/app/generated/model/update-user-dto.ts +++ b/src/main/webapp/app/generated/model/update-user-dto.ts @@ -19,7 +19,21 @@ export interface UpdateUserDTO { readonly linkedinUrl?: string; readonly nationality?: string; readonly phoneNumber?: string; + readonly primaryRole?: UpdateUserDTOPrimaryRoleEnum; + readonly researchGroupId?: string; readonly selectedLanguage?: string; readonly universityId?: string; readonly website?: string; } + +export type UpdateUserDTOPrimaryRoleEnum = 'APPLICANT' | 'PROFESSOR' | 'ADMIN' | 'EMPLOYEE'; + +export const UpdateUserDTOPrimaryRoleEnum = { + Applicant: 'APPLICANT' as const, + Professor: 'PROFESSOR' as const, + Admin: 'ADMIN' as const, + Employee: 'EMPLOYEE' as const, +} as const; + +export const UpdateUserDTOPrimaryRoleEnumValues = ['APPLICANT', 'PROFESSOR', 'ADMIN', 'EMPLOYEE'] as const; + diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html index b88d1cc52a..aff7dc7b33 100644 --- a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html @@ -206,6 +206,39 @@

+ + + + +
+
+ +
+
+ @if (requiresResearchGroup()) { + + } +
+
+ @if (mode() === 'edit' && loadedUser(); as user) { @@ -249,7 +282,7 @@

[label]="submitLabel()" icon="check" severity="primary" - [disabled]="isSubmitting() || form.invalid" + [disabled]="isSubmitting() || form.invalid || roleSelectionInvalid()" [shouldTranslate]="true" />
diff --git a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts index c246f8ad6c..8721187ac3 100644 --- a/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts @@ -4,11 +4,13 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { DividerModule } from 'primeng/divider'; import { firstValueFrom } from 'rxjs'; +import { ResearchGroupResourceApi } from 'app/generated/api/research-group-resource-api'; import { UserAdminResourceApi } from 'app/generated/api/user-admin-resource-api'; import { AdminUserDetailDTO } from 'app/generated/model/admin-user-detail-dto'; -import { CreateUserDTO } from 'app/generated/model/create-user-dto'; +import { CreateUserDTO, CreateUserDTOPrimaryRoleEnum, CreateUserDTOPrimaryRoleEnumValues } from 'app/generated/model/create-user-dto'; import { ImportUserDTO } from 'app/generated/model/import-user-dto'; -import { UpdateUserDTO } from 'app/generated/model/update-user-dto'; +import { ResearchGroupAdminDTO } from 'app/generated/model/research-group-admin-dto'; +import { UpdateUserDTO, UpdateUserDTOPrimaryRoleEnum } from 'app/generated/model/update-user-dto'; import { ToastService } from 'app/service/toast-service'; import { BackButtonComponent } from 'app/shared/components/atoms/back-button/back-button.component'; import { ButtonComponent } from 'app/shared/components/atoms/button/button.component'; @@ -28,6 +30,13 @@ type FormMode = 'create' | 'edit' | 'import'; const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/; +const GROUP_BOUND_ROLES: ReadonlySet = new Set(['EMPLOYEE', 'PROFESSOR']); + +interface ResearchGroupOption { + id: string; + name: string; +} + /** * Admin form for creating, importing, editing, and deleting a user. * The mode is derived from the route + query params: @@ -66,13 +75,34 @@ export class ManageUserFormComponent { { value: 'en', name: 'languages.en' }, { value: 'de', name: 'languages.de' }, ]; + readonly roleOptions: SelectOption[] = CreateUserDTOPrimaryRoleEnumValues.map(role => ({ + value: role, + name: `${TRANSLATION_KEY}.roles.${role}`, + })); // ------- Selected select-option signals ------- readonly selectedGender = signal(undefined); readonly selectedNationality = signal(undefined); readonly selectedLanguage = signal(undefined); + readonly selectedRole = signal(undefined); + readonly selectedResearchGroup = signal(undefined); readonly birthday = signal(''); + // ------- Research group options ------- + readonly researchGroupOptions = signal([]); + readonly researchGroupSelectOptions = computed(() => + this.researchGroupOptions().map(group => ({ value: group.id, name: group.name })), + ); + + /** True when the chosen role needs a research group attached. */ + readonly requiresResearchGroup = computed(() => { + const role = this.selectedRole()?.value as CreateUserDTOPrimaryRoleEnum | undefined; + return role !== undefined && GROUP_BOUND_ROLES.has(role); + }); + + /** True when the role/group selection prevents the submit button from enabling. */ + readonly roleSelectionInvalid = computed(() => this.requiresResearchGroup() && this.selectedResearchGroup() === undefined); + // ------- Date bounds ------- readonly minBirthday = new Date(1900, 0, 1); readonly maxBirthday = new Date(); @@ -123,6 +153,7 @@ export class ManageUserFormComponent { private readonly toastService = inject(ToastService); private readonly translate = inject(TranslateService); private readonly userAdminApi = inject(UserAdminResourceApi); + private readonly researchGroupApi = inject(ResearchGroupResourceApi); // Effect that sets mode from route params + loads user when in edit. private readonly modeEffect = effect(() => { @@ -145,11 +176,15 @@ export class ManageUserFormComponent { } }); + constructor() { + void this.loadResearchGroups(); + } + /** * Submit the form for the current mode. Routes to the matching API call. */ async onSubmit(): Promise { - if (this.form.invalid) { + if (this.form.invalid || this.roleSelectionInvalid()) { this.form.markAllAsTouched(); return; } @@ -217,6 +252,19 @@ export class ManageUserFormComponent { this.selectedLanguage.set(option); } + updateRole(option: SelectOption | undefined): void { + this.selectedRole.set(option); + // Drop the research group when the new role does not allow one. + const role = option?.value as CreateUserDTOPrimaryRoleEnum | undefined; + if (role === undefined || !GROUP_BOUND_ROLES.has(role)) { + this.selectedResearchGroup.set(undefined); + } + } + + updateResearchGroup(option: SelectOption | undefined): void { + this.selectedResearchGroup.set(option); + } + updateBirthday(value: string | undefined): void { this.birthday.set(value ?? ''); } @@ -278,12 +326,35 @@ export class ManageUserFormComponent { this.selectedGender.set(this.findOption(this.genderOptions, user.gender)); this.selectedNationality.set(this.findOption(this.nationalityOptionsBase, user.nationality)); this.selectedLanguage.set(this.findOption(this.languageOptions, user.selectedLanguage)); + this.selectedRole.set(this.findOption(this.roleOptions, user.primaryRole)); + this.selectedResearchGroup.set(this.findOption(this.researchGroupSelectOptions(), user.researchGroupId)); this.birthday.set(user.birthday ?? ''); } catch { this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.loadUser`); } } + private async loadResearchGroups(): Promise { + try { + const page = await firstValueFrom(this.researchGroupApi.getResearchGroupsForAdmin(1000, 0)); + const options: ResearchGroupOption[] = (page.content ?? []) + .filter( + (group: ResearchGroupAdminDTO): group is ResearchGroupAdminDTO & { id: string; researchGroup: string } => + Boolean(group.id) && Boolean(group.researchGroup), + ) + .map(group => ({ id: group.id, name: group.researchGroup })) + .sort((a, b) => a.name.localeCompare(b.name)); + this.researchGroupOptions.set(options); + // Reapply the selection now that the option list exists. + const loaded = this.loadedUser(); + if (loaded?.researchGroupId !== undefined) { + this.selectedResearchGroup.set(this.findOption(this.researchGroupSelectOptions(), loaded.researchGroupId)); + } + } catch { + this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.loadFilters`); + } + } + private async submitCreate(): Promise { const value = this.form.getRawValue(); const dto: CreateUserDTO = { @@ -299,6 +370,8 @@ export class ManageUserFormComponent { nationality: this.selectOptionValue(this.selectedNationality()), selectedLanguage: this.selectOptionValue(this.selectedLanguage()), birthday: this.emptyToUndefined(this.birthday()), + primaryRole: this.selectedRole()?.value as CreateUserDTOPrimaryRoleEnum | undefined, + researchGroupId: this.requiresResearchGroup() ? this.selectOptionValue(this.selectedResearchGroup()) : undefined, }; try { @@ -340,6 +413,13 @@ export class ManageUserFormComponent { return; } const value = this.form.getRawValue(); + const loadedRole = this.loadedUser()?.primaryRole; + const selectedRole = this.selectedRole()?.value as UpdateUserDTOPrimaryRoleEnum | undefined; + const loadedGroupId = this.loadedUser()?.researchGroupId; + const selectedGroupId = this.selectOptionValue(this.selectedResearchGroup()); + const roleChanged = selectedRole !== loadedRole; + const groupChanged = (selectedGroupId ?? undefined) !== (loadedGroupId ?? undefined); + const sendRole = roleChanged || groupChanged; const dto: UpdateUserDTO = { firstName: value.firstName, lastName: value.lastName, @@ -351,6 +431,8 @@ export class ManageUserFormComponent { nationality: this.selectOptionValue(this.selectedNationality()), selectedLanguage: this.selectOptionValue(this.selectedLanguage()), birthday: this.emptyToUndefined(this.birthday()), + primaryRole: sendRole ? selectedRole : undefined, + researchGroupId: sendRole && this.requiresResearchGroup() ? selectedGroupId : undefined, }; try { diff --git a/src/main/webapp/i18n/de/languages.json b/src/main/webapp/i18n/de/languages.json new file mode 100644 index 0000000000..4d82ac2810 --- /dev/null +++ b/src/main/webapp/i18n/de/languages.json @@ -0,0 +1,6 @@ +{ + "languages": { + "en": "Englisch", + "de": "Deutsch" + } +} diff --git a/src/main/webapp/i18n/de/usermanagement.json b/src/main/webapp/i18n/de/usermanagement.json index 54f664fcf9..78ad4f36e5 100644 --- a/src/main/webapp/i18n/de/usermanagement.json +++ b/src/main/webapp/i18n/de/usermanagement.json @@ -36,6 +36,7 @@ "importTitle": "Nutzende aus Keycloak importieren", "identitySection": "Identität", "profileSection": "Profil", + "roleSection": "Rolle", "statusSection": "Status", "fields": { "firstName": "Vorname", @@ -52,6 +53,8 @@ "website": "Website", "linkedinUrl": "LinkedIn", "selectedLanguage": "Sprache", + "primaryRole": "Rolle", + "researchGroup": "Forschungsgruppe", "avatar": "Avatar-URL", "createdAt": "Erstellt", "lastActivityAt": "Letzte Aktivität", diff --git a/src/main/webapp/i18n/en/languages.json b/src/main/webapp/i18n/en/languages.json new file mode 100644 index 0000000000..f5acb87c6a --- /dev/null +++ b/src/main/webapp/i18n/en/languages.json @@ -0,0 +1,6 @@ +{ + "languages": { + "en": "English", + "de": "German" + } +} diff --git a/src/main/webapp/i18n/en/usermanagement.json b/src/main/webapp/i18n/en/usermanagement.json index 7fd62d5157..78f6509ef6 100644 --- a/src/main/webapp/i18n/en/usermanagement.json +++ b/src/main/webapp/i18n/en/usermanagement.json @@ -36,6 +36,7 @@ "importTitle": "Import User from Keycloak", "identitySection": "Identity", "profileSection": "Profile", + "roleSection": "Role", "statusSection": "Status", "fields": { "firstName": "First Name", @@ -52,6 +53,8 @@ "website": "Website", "linkedinUrl": "LinkedIn", "selectedLanguage": "Language", + "primaryRole": "Role", + "researchGroup": "Research Group", "avatar": "Avatar URL", "createdAt": "Created", "lastActivityAt": "Last Activity", diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts index 87b595f22c..a23040ea26 100644 --- a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts +++ b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts @@ -16,9 +16,15 @@ import { provideUserAdminResourceApiMock, UserAdminResourceApiMock, } from 'util/user-admin-resource-api.service.mock'; +import { + createResearchGroupResourceApiMock, + provideResearchGroupResourceApiMock, + ResearchGroupResourceApiMock, +} from 'util/research-group-resource-api.service.mock'; describe('ManageUserFormComponent', () => { let mockUserAdminApi: UserAdminResourceApiMock; + let mockResearchGroupApi: ResearchGroupResourceApiMock; let mockToastService: ToastServiceMock; let mockRouter: RouterMock; @@ -49,6 +55,17 @@ describe('ManageUserFormComponent', () => { mockUserAdminApi.updateUser.mockReturnValue(of({ userId: 'user-1', firstName: 'Alicia' } as AdminUserDetailDTO)); mockUserAdminApi.deleteUser.mockReturnValue(of(undefined)); + mockResearchGroupApi = createResearchGroupResourceApiMock(); + mockResearchGroupApi.getResearchGroupsForAdmin.mockReturnValue( + of({ + content: [ + { id: 'rg-1', researchGroup: 'AI Lab' }, + { id: 'rg-2', researchGroup: 'ML Lab' }, + ], + totalElements: 2, + }), + ); + mockToastService = createToastServiceMock(); mockRouter = createRouterMock(); @@ -56,6 +73,7 @@ describe('ManageUserFormComponent', () => { imports: [ManageUserFormComponent], providers: [ provideUserAdminResourceApiMock(mockUserAdminApi), + provideResearchGroupResourceApiMock(mockResearchGroupApi), provideToastServiceMock(mockToastService), provideTranslateMock(createTranslateServiceMock()), provideFontAwesomeTesting(), @@ -112,6 +130,8 @@ describe('ManageUserFormComponent', () => { TestBed.resetTestingModule(); mockUserAdminApi = createUserAdminResourceApiMock(); mockUserAdminApi.getUser.mockReturnValue(throwError(() => new Error('not found'))); + mockResearchGroupApi = createResearchGroupResourceApiMock(); + mockResearchGroupApi.getResearchGroupsForAdmin.mockReturnValue(of({ content: [], totalElements: 0 })); mockToastService = createToastServiceMock(); mockRouter = createRouterMock(); @@ -119,6 +139,7 @@ describe('ManageUserFormComponent', () => { imports: [ManageUserFormComponent], providers: [ provideUserAdminResourceApiMock(mockUserAdminApi), + provideResearchGroupResourceApiMock(mockResearchGroupApi), provideToastServiceMock(mockToastService), provideTranslateMock(createTranslateServiceMock()), provideFontAwesomeTesting(), From dd1b5457c3cb782ac021a6f8d7b72333bc450745 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 16:28:58 +0000 Subject: [PATCH 23/28] chore: update OpenAPI spec and generated client --- openapi/openapi.yaml | 318 ------------------ .../app/generated/.openapi-generator/FILES | 3 - 2 files changed, 321 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 7eb89efd4b..01b52f3fbe 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2,10 +2,6 @@ openapi: 3.1.0 info: {title: OpenAPI definition, version: v0} servers: - {url: 'http://localhost:8080', description: Generated server url} -tags: -- name: Actuator - description: Monitor and interact - externalDocs: {description: Spring Boot Actuator Web API Documentation, url: 'https://docs.spring.io/spring-boot/docs/current/actuator-api/html/'} paths: /api/admin/dependencies: get: @@ -2872,315 +2868,6 @@ paths: required: true responses: '200': {description: OK} - /management: - get: - tags: [Actuator] - summary: Actuator root web endpoint - operationId: links - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: - type: object - additionalProperties: - type: object - additionalProperties: {$ref: '#/components/schemas/Link'} - application/vnd.spring-boot.actuator.v2+json: - schema: - type: object - additionalProperties: - type: object - additionalProperties: {$ref: '#/components/schemas/Link'} - application/json: - schema: - type: object - additionalProperties: - type: object - additionalProperties: {$ref: '#/components/schemas/Link'} - /management/caches: - get: - tags: [Actuator] - summary: Actuator web endpoint 'caches' - operationId: caches - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - delete: - tags: [Actuator] - summary: Actuator web endpoint 'caches' - operationId: clearCaches - responses: - '204': {description: No Content} - /management/caches/{cache}: - get: - tags: [Actuator] - summary: Actuator web endpoint 'caches-cache' - operationId: cache - parameters: - - name: cache - in: path - required: true - schema: {type: string} - - name: cacheManager - in: query - schema: {type: string} - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - '404': {description: Not Found} - delete: - tags: [Actuator] - summary: Actuator web endpoint 'caches-cache' - operationId: clearCache - parameters: - - name: cache - in: path - required: true - schema: {type: string} - - name: cacheManager - in: query - schema: {type: string} - responses: - '204': {description: No Content} - '404': {description: Not Found} - /management/configprops: - get: - tags: [Actuator] - summary: Actuator web endpoint 'configprops' - operationId: configurationProperties - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/configprops/{prefix}: - get: - tags: [Actuator] - summary: Actuator web endpoint 'configprops-prefix' - operationId: configurationPropertiesWithPrefix - parameters: - - name: prefix - in: path - required: true - schema: {type: string} - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - '404': {description: Not Found} - /management/env: - get: - tags: [Actuator] - summary: Actuator web endpoint 'env' - operationId: environment - parameters: - - name: pattern - in: query - schema: {type: string} - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/env/{toMatch}: - get: - tags: [Actuator] - summary: Actuator web endpoint 'env-toMatch' - operationId: environmentEntry - parameters: - - name: toMatch - in: path - required: true - schema: {type: string} - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - '404': {description: Not Found} - /management/health: - get: - tags: [Actuator] - summary: Actuator web endpoint 'health' - operationId: health - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/info: - get: - tags: [Actuator] - summary: Actuator web endpoint 'info' - operationId: info - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/jhimetrics: - get: - tags: [Actuator] - summary: Actuator web endpoint 'jhimetrics' - operationId: allMetrics - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/jhiopenapigroups: - get: - tags: [Actuator] - summary: Actuator web endpoint 'jhiopenapigroups' - operationId: allOpenApi - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/liquibase: - get: - tags: [Actuator] - summary: Actuator web endpoint 'liquibase' - operationId: liquibaseBeans - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/loggers: - get: - tags: [Actuator] - summary: Actuator web endpoint 'loggers' - operationId: loggers - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - /management/loggers/{name}: - get: - tags: [Actuator] - summary: Actuator web endpoint 'loggers-name' - operationId: loggerLevels - parameters: - - name: name - in: path - required: true - schema: {type: string} - responses: - '200': - description: OK - content: - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} - '404': {description: Not Found} - post: - tags: [Actuator] - summary: Actuator web endpoint 'loggers-name' - operationId: configureLogLevel - parameters: - - name: name - in: path - required: true - schema: {type: string} - requestBody: - content: - application/json: - schema: - type: string - enum: [TRACE, DEBUG, INFO, WARN, ERROR, FATAL, 'OFF'] - responses: - '204': {description: No Content} - '400': {description: Bad Request} - /management/threaddump: - get: - tags: [Actuator] - summary: Actuator web endpoint 'threaddump' - operationId: threadDump - responses: - '200': - description: OK - content: - text/plain;charset=UTF-8: - schema: {type: object} - application/vnd.spring-boot.actuator.v3+json: - schema: {type: object} - application/vnd.spring-boot.actuator.v2+json: - schema: {type: object} - application/json: - schema: {type: object} components: schemas: AcceptDTO: @@ -4065,11 +3752,6 @@ components: lastName: {type: string} universityId: {type: string} username: {type: string} - Link: - type: object - properties: - href: {type: string} - templated: {type: boolean} LoginRequestDTO: type: object properties: diff --git a/src/main/webapp/app/generated/.openapi-generator/FILES b/src/main/webapp/app/generated/.openapi-generator/FILES index a1587e7ad1..49e7fb62b1 100644 --- a/src/main/webapp/app/generated/.openapi-generator/FILES +++ b/src/main/webapp/app/generated/.openapi-generator/FILES @@ -1,5 +1,3 @@ -api/actuator-api.ts -api/actuator-resources.ts api/admin-dependency-resource-api.ts api/admin-dependency-resource-resources.ts api/admin-export-resource-api.ts @@ -123,7 +121,6 @@ model/job-form-dto.ts model/job-preview-request.ts model/keycloak-config.ts model/keycloak-user-dto.ts -model/link.ts model/login-request-dto.ts model/otp-complete-dto.ts model/otp-config.ts From 1deab0f3bd55579651ff001fdf076963ef11531b Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Thu, 21 May 2026 18:35:46 +0200 Subject: [PATCH 24/28] `General`: Move admin role assignment into UserService UserAdminService no longer reaches into UserResearchGroupRoleRepository or ResearchGroupRepository directly. The role-replace logic moves into UserService.setPrimaryRole, which keeps its own @Transactional. Outer @Transactional on create/update is dropped since each delegated service call owns its transaction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/UserAdminService.java | 65 +------------------ .../usermanagement/service/UserService.java | 46 +++++++++++++ 2 files changed, 48 insertions(+), 63 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java index 4c51b8939d..c8724eb17a 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java @@ -3,25 +3,20 @@ import de.tum.cit.aet.core.dto.PageDTO; import de.tum.cit.aet.core.dto.SortDTO; import de.tum.cit.aet.core.exception.EntityNotFoundException; -import de.tum.cit.aet.core.exception.InvalidParameterException; import de.tum.cit.aet.core.exception.OperationNotAllowedException; import de.tum.cit.aet.core.retention.UserRetentionService; import de.tum.cit.aet.core.service.CurrentUserService; import de.tum.cit.aet.core.util.PageUtil; import de.tum.cit.aet.core.util.StringUtil; import de.tum.cit.aet.usermanagement.constants.UserRole; -import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.User; -import de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole; import de.tum.cit.aet.usermanagement.dto.AdminUserDetailDTO; import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; import de.tum.cit.aet.usermanagement.dto.KeycloakUserDTO; import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; -import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; import de.tum.cit.aet.usermanagement.repository.UserRepository; -import de.tum.cit.aet.usermanagement.repository.UserResearchGroupRoleRepository; import java.util.Comparator; import java.util.List; import java.util.UUID; @@ -29,7 +24,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * Orchestrates Keycloak and local-DB user management for admins. @@ -41,8 +35,6 @@ public class UserAdminService { private final UserRepository userRepository; - private final UserResearchGroupRoleRepository userResearchGroupRoleRepository; - private final ResearchGroupRepository researchGroupRepository; private final KeycloakUserService keycloakUserService; private final UserService userService; private final UserRetentionService userRetentionService; @@ -127,9 +119,7 @@ public AdminUserDetailDTO getUserDetail(UUID userId) { * @param dto the create-user payload * @return the new user's UUID */ - @Transactional public UUID create(CreateUserDTO dto) { - validateRoleAndGroup(dto.primaryRole(), dto.researchGroupId()); // 1) Keycloak first — failure aborts before any DB write. UUID userId = keycloakUserService.createUserWithPassword(dto.email(), dto.firstName(), dto.lastName(), dto.password()); // 2) Local DB upsert (creates user + assigns APPLICANT role if missing). @@ -138,7 +128,7 @@ public UUID create(CreateUserDTO dto) { applyOptionalCreateFields(userId, dto); // 4) Apply primary role assignment when supplied. if (dto.primaryRole() != null) { - applyPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); + userService.setPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); } return userId; } @@ -166,9 +156,7 @@ public UUID importFromKeycloak(ImportUserDTO dto) { * @param dto the update payload (any null field is left untouched) * @throws EntityNotFoundException if no user exists with the given ID */ - @Transactional public void update(UUID userId, UpdateUserDTO dto) { - validateRoleAndGroup(dto.primaryRole(), dto.researchGroupId()); if (dto.primaryRole() != null && userId.equals(currentUserService.getUserId())) { throw new OperationNotAllowedException("Admins cannot change their own role."); } @@ -211,7 +199,7 @@ public void update(UUID userId, UpdateUserDTO dto) { } userRepository.save(user); if (dto.primaryRole() != null) { - applyPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); + userService.setPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); } } @@ -278,55 +266,6 @@ private void applyOptionalCreateFields(UUID userId, CreateUserDTO dto) { } } - /** - * Validates that the role/group combination is consistent: EMPLOYEE and PROFESSOR - * require a research group; APPLICANT and ADMIN must be supplied without one. - * - * @param role the requested primary role (may be null) - * @param researchGroupId the requested research group (may be null) - */ - private void validateRoleAndGroup(UserRole role, UUID researchGroupId) { - if (role == null) { - return; - } - boolean groupBound = role == UserRole.EMPLOYEE || role == UserRole.PROFESSOR; - if (groupBound && researchGroupId == null) { - throw new InvalidParameterException("Role " + role + " requires a researchGroupId."); - } - if (!groupBound && researchGroupId != null) { - throw new InvalidParameterException("Role " + role + " must not be assigned to a research group."); - } - } - - /** - * Replaces all existing role mappings of a user with a single mapping that matches - * the requested role. EMPLOYEE/PROFESSOR roles are attached to the supplied research - * group; APPLICANT and ADMIN are attached to no group. The user's primary research - * group on the {@link User} entity is kept in sync with the new mapping. - * - * @param userId the user being updated - * @param role the new primary role - * @param researchGroupId the research group for EMPLOYEE/PROFESSOR, otherwise null - */ - private void applyPrimaryRole(UUID userId, UserRole role, UUID researchGroupId) { - User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); - ResearchGroup group = researchGroupId == null ? null : researchGroupRepository.findByIdElseThrow(researchGroupId); - - // 1) Drop existing role mappings — admin-driven role swap is a hard replace. - userResearchGroupRoleRepository.deleteByUserId(userId); - - // 2) Create the new role mapping. - UserResearchGroupRole mapping = new UserResearchGroupRole(); - mapping.setUser(user); - mapping.setRole(role); - mapping.setResearchGroup(group); - userResearchGroupRoleRepository.save(mapping); - - // 3) Keep the primary research-group reference on the user aligned with the new role. - user.setResearchGroup(group); - userRepository.save(user); - } - /** * Ranks roles so the highest-privilege role is selected as the user's primary role. * diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java index b5e1e4a14c..96fd9e2245 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java @@ -3,12 +3,15 @@ import de.tum.cit.aet.core.dto.PageDTO; import de.tum.cit.aet.core.dto.PageResponseDTO; import de.tum.cit.aet.core.exception.EntityNotFoundException; +import de.tum.cit.aet.core.exception.InvalidParameterException; import de.tum.cit.aet.core.service.ImageService; import de.tum.cit.aet.core.util.StringUtil; import de.tum.cit.aet.usermanagement.constants.UserRole; +import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.User; import de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole; import de.tum.cit.aet.usermanagement.dto.UserShortDTO; +import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; import de.tum.cit.aet.usermanagement.repository.UserRepository; import de.tum.cit.aet.usermanagement.repository.UserResearchGroupRoleRepository; import java.time.Duration; @@ -31,16 +34,19 @@ public class UserService { private final UserRepository userRepository; private final UserResearchGroupRoleRepository userResearchGroupRoleRepository; + private final ResearchGroupRepository researchGroupRepository; private final ImageService imageService; private static final Duration LAST_ACTIVITY_UPDATE_THRESHOLD = Duration.ofHours(24); public UserService( UserRepository userRepository, UserResearchGroupRoleRepository userResearchGroupRoleRepository, + ResearchGroupRepository researchGroupRepository, ImageService imageService ) { this.userRepository = userRepository; this.userResearchGroupRoleRepository = userResearchGroupRoleRepository; + this.researchGroupRepository = researchGroupRepository; this.imageService = imageService; } @@ -196,6 +202,46 @@ public PageResponseDTO getAvailableUsersForResearchGroup(PageDTO p return new PageResponseDTO<>(userDTOs, userIdsPage.getTotalElements()); } + /** + * Replaces all existing role mappings of a user with a single mapping that matches + * the requested role. EMPLOYEE and PROFESSOR roles must be attached to a research + * group; APPLICANT and ADMIN must be passed without one. The user's primary + * research-group reference is kept in sync with the new mapping. + * + * @param userId the user being updated + * @param role the new primary role + * @param researchGroupId the research group for EMPLOYEE/PROFESSOR, otherwise null + * @throws InvalidParameterException if the role/group combination is inconsistent + * @throws EntityNotFoundException if no user or research group exists with the given ID + */ + @Transactional + public void setPrimaryRole(UUID userId, UserRole role, UUID researchGroupId) { + boolean groupBound = role == UserRole.EMPLOYEE || role == UserRole.PROFESSOR; + if (groupBound && researchGroupId == null) { + throw new InvalidParameterException("Role " + role + " requires a researchGroupId."); + } + if (!groupBound && researchGroupId != null) { + throw new InvalidParameterException("Role " + role + " must not be assigned to a research group."); + } + + User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); + ResearchGroup group = researchGroupId == null ? null : researchGroupRepository.findByIdElseThrow(researchGroupId); + + // 1) Drop existing role mappings — admin-driven role swap is a hard replace. + userResearchGroupRoleRepository.deleteByUserId(userId); + + // 2) Create the new role mapping. + UserResearchGroupRole mapping = new UserResearchGroupRole(); + mapping.setUser(user); + mapping.setRole(role); + mapping.setResearchGroup(group); + userResearchGroupRoleRepository.save(mapping); + + // 3) Keep the primary research-group reference on the user aligned with the new role. + user.setResearchGroup(group); + userRepository.save(user); + } + /** * Ensures the user has at least one role by assigning {@link UserRole APPLICANT} when none are present. * From 208090a1ad83bc70e8a124818164387edbc789e6 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Thu, 21 May 2026 19:05:35 +0200 Subject: [PATCH 25/28] \`Test\`: Cover UserAdminResource update + delete flows UserService is mocked in the resource test slice, so the new tests verify that update delegates to userService.setPrimaryRole with the right arguments (or skips it), exercise the self-role guard, and round-trip a profile-field-only update through the DB. Restores jacoco coverage that dropped when UserAdminServiceTest was removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/rest/UserAdminResourceTest.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java index 18c5f67364..3042c95dbe 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java @@ -1,13 +1,19 @@ package de.tum.cit.aet.usermanagement.web.rest; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; import de.tum.cit.aet.AbstractResourceTest; +import de.tum.cit.aet.usermanagement.constants.UserRole; import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.User; import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; +import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; import de.tum.cit.aet.usermanagement.repository.UserRepository; +import de.tum.cit.aet.usermanagement.service.UserService; import de.tum.cit.aet.utility.DatabaseCleaner; import de.tum.cit.aet.utility.MvcTestClient; import de.tum.cit.aet.utility.PageResponse; @@ -35,6 +41,9 @@ class UserAdminResourceTest extends AbstractResourceTest { @Autowired ResearchGroupRepository researchGroupRepository; + @Autowired + UserService userService; + @Autowired DatabaseCleaner databaseCleaner; @@ -48,6 +57,7 @@ class UserAdminResourceTest extends AbstractResourceTest { @BeforeEach void setup() { databaseCleaner.clean(); + reset(userService); api.withoutPostProcessors(); researchGroup = ResearchGroupTestData.savedAll( @@ -143,4 +153,105 @@ void shouldRejectProfessor() { .postAndRead("/api/admin/users", payload, Void.class, 403); } } + + @Nested + class UpdateUser { + + @Test + void shouldUpdateProfileFieldsWithoutRoleChange() { + User target = UserTestData.savedUser(userRepository); + UpdateUserDTO dto = new UpdateUserDTO( + "Renamed", + null, + null, + "+49 89 0000", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .putAndRead("/api/admin/users/" + target.getUserId(), dto, Void.class, 200); + + User reloaded = userRepository.findById(target.getUserId()).orElseThrow(); + assertThat(reloaded.getFirstName()).isEqualTo("Renamed"); + assertThat(reloaded.getPhoneNumber()).isEqualTo("+49 89 0000"); + verify(userService, never()).setPrimaryRole(target.getUserId(), null, null); + } + + @Test + void shouldDelegateRoleAssignmentToUserService() { + User target = UserTestData.savedUser(userRepository); + UpdateUserDTO dto = roleUpdate(UserRole.PROFESSOR, researchGroup.getResearchGroupId()); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .putAndRead("/api/admin/users/" + target.getUserId(), dto, Void.class, 200); + + verify(userService).setPrimaryRole(target.getUserId(), UserRole.PROFESSOR, researchGroup.getResearchGroupId()); + } + + @Test + void shouldRejectAdminChangingOwnRole() { + UpdateUserDTO dto = roleUpdate(UserRole.APPLICANT, null); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .putAndRead("/api/admin/users/" + adminUser.getUserId(), dto, Void.class, 400); + + verify(userService, never()).setPrimaryRole(adminUser.getUserId(), UserRole.APPLICANT, null); + } + + @Test + void shouldRejectProfessor() { + User target = UserTestData.savedUser(userRepository); + UpdateUserDTO dto = new UpdateUserDTO( + "Blocked", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + + api + .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) + .putAndRead("/api/admin/users/" + target.getUserId(), dto, Void.class, 403); + } + + private UpdateUserDTO roleUpdate(UserRole role, UUID researchGroupId) { + return new UpdateUserDTO( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + role, + researchGroupId + ); + } + } } From 1494b472b7fd7806e4b45f10baf6677b86c8fffd Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Thu, 21 May 2026 19:10:56 +0200 Subject: [PATCH 26/28] Test: Fix prettier formatting in UserAdminResourceTest Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/rest/UserAdminResourceTest.java | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java index 3042c95dbe..61d1c031bc 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java @@ -213,22 +213,7 @@ void shouldRejectAdminChangingOwnRole() { @Test void shouldRejectProfessor() { User target = UserTestData.savedUser(userRepository); - UpdateUserDTO dto = new UpdateUserDTO( - "Blocked", - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ); + UpdateUserDTO dto = new UpdateUserDTO("Blocked", null, null, null, null, null, null, null, null, null, null, null, null, null); api .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) @@ -236,22 +221,7 @@ void shouldRejectProfessor() { } private UpdateUserDTO roleUpdate(UserRole role, UUID researchGroupId) { - return new UpdateUserDTO( - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - role, - researchGroupId - ); + return new UpdateUserDTO(null, null, null, null, null, null, null, null, null, null, null, null, role, researchGroupId); } } } From 173a0c08f71abfd8b64114fece3062ea642bb94d Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sun, 26 Jul 2026 12:46:23 +0200 Subject: [PATCH 27/28] `General`: Address the review findings on the admin user management - manage-user-form spec: built the second candidate explicitly instead of spreading the first. The client guidelines call out the spread operator with this exact pattern as the counter-example, and it was one of only two uses across the spec suite. - UserAdminResource: the delete endpoint's javadoc still claimed the user is removed from Keycloak. That stopped being true when the Keycloak call was dropped from the flow, and it contradicted the service javadoc directly below it. It now says what the endpoint does, which is anonymise the local record and leave the Keycloak identity alone. - UserAdminResourceTest: the create and import payloads went from untyped maps to the CreateUserDTO and ImportUserDTO records, matching the sibling UpdateUser tests and the rest of the resource tests. One payload stays a map deliberately. ImportUserDTO is @JsonInclude(NON_EMPTY), so new ImportUserDTO("") serialises to {} and the blank-value test would still pass while no longer sending a blank value. There is now a comment saying so. Verified: 804 server tests, 2063 client tests, typecheck, eslint with no errors and prettier. Co-Authored-By: Claude --- .../usermanagement/web/UserAdminResource.java | 3 +- .../web/rest/UserAdminResourceTest.java | 41 +++++++++++-------- .../manage-user-form.component.spec.ts | 9 +++- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java b/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java index 5b7571a7d2..8b17d1b15f 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java @@ -123,7 +123,8 @@ public ResponseEntity updateUser(@PathVariable UUID userId, } /** - * {@code DELETE /api/admin/users/{userId}} : Deletes a user from Keycloak and anonymises their local-DB references. + * {@code DELETE /api/admin/users/{userId}} : Anonymises the user's local-DB references. TUM members keep + * their Keycloak identity, so nothing is removed there. * * @param userId the user id to delete * @return the {@link ResponseEntity} with status {@code 204 (No Content)} diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java index 472e7b8fbc..ca79681470 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java @@ -13,6 +13,8 @@ import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.User; import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; +import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; +import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; import de.tum.cit.aet.usermanagement.dto.KeycloakUserDTO; import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; @@ -152,14 +154,9 @@ void shouldRejectInvalidPayload() { @Test void shouldRejectProfessor() { - Map payload = new HashMap<>(); - payload.put("firstName", "New"); - payload.put("lastName", "User"); - payload.put("email", "new.user@tum.de"); - payload.put("password", "supersecure1"); api .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) - .postAndRead("/api/admin/users", payload, Void.class, 403); + .postAndRead("/api/admin/users", createPayload(), Void.class, 403); } @Test @@ -203,13 +200,23 @@ void shouldRejectWhenPasswordCannotBeSetForATumMember() { .postAndRead("/api/admin/users", createPayload(), Void.class, 400); } - private Map createPayload() { - Map payload = new HashMap<>(); - payload.put("firstName", "New"); - payload.put("lastName", "User"); - payload.put("email", "new.user@tum.de"); - payload.put("password", "supersecure1"); - return payload; + private CreateUserDTO createPayload() { + return new CreateUserDTO( + "New", + "User", + "new.user@tum.de", + "supersecure1", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); } } @@ -225,7 +232,7 @@ void shouldImportTumMemberResolvedByUniversityId() { api .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) - .postAndRead("/api/admin/users/import", Map.of("universityId", "ab12cde"), Void.class, 201); + .postAndRead("/api/admin/users/import", new ImportUserDTO("ab12cde"), Void.class, 201); verify(keycloakUserService).findUserByUniversityId("ab12cde"); assertThat(userRepository.findById(imported.getUserId()).orElseThrow().getUniversityId()).isEqualTo("ab12cde"); @@ -237,13 +244,15 @@ void shouldReturn404WhenUniversityIdIsUnknownInKeycloak() { api .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) - .postAndRead("/api/admin/users/import", Map.of("universityId", "zz99zzz"), Void.class, 404); + .postAndRead("/api/admin/users/import", new ImportUserDTO("zz99zzz"), Void.class, 404); } @Test void shouldRejectBlankUniversityId() { api .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + // Sent as a map on purpose: ImportUserDTO is @JsonInclude(NON_EMPTY), so an empty string + // would serialise to {} and this would stop covering a blank value. .postAndRead("/api/admin/users/import", Map.of("universityId", ""), Void.class, 400); } @@ -251,7 +260,7 @@ void shouldRejectBlankUniversityId() { void shouldRejectProfessor() { api .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) - .postAndRead("/api/admin/users/import", Map.of("universityId", "ab12cde"), Void.class, 403); + .postAndRead("/api/admin/users/import", new ImportUserDTO("ab12cde"), Void.class, 403); } } diff --git a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts index c5b80b4cf6..dfbc78b193 100644 --- a/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts +++ b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts @@ -448,7 +448,14 @@ describe('ManageUserFormComponent', () => { it('should append the next page when loading more candidates', async () => { const fixture = await setupComponent({}, { mode: 'import' }); const component = fixture.componentInstance; - const secondUser: KeycloakUserDTO = { ...tumUser, id: 'kc-3', universityId: 'gb34def' }; + const secondUser: KeycloakUserDTO = { + id: 'kc-3', + username: tumUser.username, + firstName: tumUser.firstName, + lastName: tumUser.lastName, + email: tumUser.email, + universityId: 'gb34def', + }; mockUserApi.getAvailableUsersForResearchGroup.mockReturnValue(of({ content: [tumUser], totalElements: 2 })); await component.onImportUserSearch('bob'); From 01db6217dacbd95b94fb4da0b01d8651e8c8ed63 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Thu, 30 Jul 2026 12:52:20 +0200 Subject: [PATCH 28/28] `Bugfix`: Keep a user's other research groups when an admin changes their role Setting a role deleted every role mapping the user had and wrote one back, so an admin changing someone's role in one group silently removed them from every other group they belonged to. The form says "set the role", not "remove from all other groups". A group-bound role now only replaces the role held in the group it names. A role that belongs to no group still clears the group-bound ones, since that is a demotion out of every group rather than a change within one. Covering this meant the test context could no longer replace UserService with a plain mock, because the endpoint an admin actually calls delegates to it and nothing ran. It is a spy now, so unstubbed methods run against the real database and the two new tests assert what the role change leaves behind. That change makes when(...) on this service dangerous: stubbing that way calls the real method first, which counted an extra invocation in one test and would have written a real password hash while setting up another. The twenty existing stubs are doReturn/doThrow now, which do not. Co-Authored-By: Claude --- .../usermanagement/service/UserService.java | 41 ++++++++--- .../cit/aet/TestSecurityConfiguration.java | 29 +++++++- .../oauth2/OAuth2LoginSuccessHandlerTest.java | 3 +- .../WebAuthnLoginSuccessHandlerTest.java | 8 ++- .../web/AuthenticationResourceTest.java | 11 +-- .../web/rest/UserAdminResourceTest.java | 69 ++++++++++++++++--- .../web/rest/UserResourceTest.java | 7 +- 7 files changed, 135 insertions(+), 33 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java index 4d99d6e43d..315539c9d9 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java @@ -267,13 +267,15 @@ public PageResponseDTO getAvailableUsersForResearchGroup(PageDTO p } /** - * Replaces all existing role mappings of a user with a single mapping that matches - * the requested role. EMPLOYEE and PROFESSOR roles must be attached to a research - * group; APPLICANT and ADMIN must be passed without one. The user's primary - * research-group reference is kept in sync with the new mapping. + * Sets the role a user holds. EMPLOYEE and PROFESSOR must be attached to a research group; + * APPLICANT and ADMIN must be passed without one. + * + * A user may belong to several research groups, so a group-bound role only replaces the role + * held in the named group and leaves memberships of other groups alone. A role that belongs to + * no group is a demotion out of every group, and clears the group-bound mappings. * * @param userId the user being updated - * @param role the new primary role + * @param role the new role * @param researchGroupId the research group for EMPLOYEE/PROFESSOR, otherwise null * @throws InvalidParameterException if the role/group combination is inconsistent * @throws EntityNotFoundException if no user or research group exists with the given ID @@ -289,18 +291,35 @@ public void setPrimaryRole(UUID userId, UserRole role, UUID researchGroupId) { } User user = userRepository.findById(userId).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); - ResearchGroup group = researchGroupId == null ? null : researchGroupRepository.findByIdElseThrow(researchGroupId); - // 1) Drop existing role mappings — admin-driven role swap is a hard replace. - userResearchGroupRoleRepository.deleteByUserId(userId); + if (!groupBound) { + // The user holds no group-bound role any more, so the mappings that carried them go. + userResearchGroupRoleRepository.deleteByUserId(userId); + userResearchGroupRoleRepository.save(newRoleMapping(user, role, null)); + return; + } + + // Only the role held in this group changes; the user's other groups are none of this call's business. + ResearchGroup group = researchGroupRepository.findByIdElseThrow(researchGroupId); + UserResearchGroupRole mapping = userResearchGroupRoleRepository + .findByUserAndResearchGroup(user, group) + .orElseGet(() -> newRoleMapping(user, role, group)); + mapping.setRole(role); + userResearchGroupRoleRepository.save(mapping); + } - // 2) Create the new role mapping — the UserResearchGroupRole is the single source of truth - // for the user's group membership (the legacy User.researchGroup column no longer exists). + /** + * @param user the user the mapping belongs to + * @param role the role held + * @param group the research group the role is held in, or {@code null} for a role that belongs to none + * @return an unsaved role mapping + */ + private static UserResearchGroupRole newRoleMapping(User user, UserRole role, ResearchGroup group) { UserResearchGroupRole mapping = new UserResearchGroupRole(); mapping.setUser(user); mapping.setRole(role); mapping.setResearchGroup(group); - userResearchGroupRoleRepository.save(mapping); + return mapping; } /** diff --git a/src/test/java/de/tum/cit/aet/TestSecurityConfiguration.java b/src/test/java/de/tum/cit/aet/TestSecurityConfiguration.java index 165df1f7cd..0ae9c9916d 100644 --- a/src/test/java/de/tum/cit/aet/TestSecurityConfiguration.java +++ b/src/test/java/de/tum/cit/aet/TestSecurityConfiguration.java @@ -1,6 +1,10 @@ package de.tum.cit.aet; import de.tum.cit.aet.core.service.AuthenticationService; +import de.tum.cit.aet.core.service.ImageService; +import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; +import de.tum.cit.aet.usermanagement.repository.UserRepository; +import de.tum.cit.aet.usermanagement.repository.UserResearchGroupRoleRepository; import de.tum.cit.aet.usermanagement.service.KeycloakAuthenticationService; import de.tum.cit.aet.usermanagement.service.KeycloakUserService; import de.tum.cit.aet.usermanagement.service.UserService; @@ -8,6 +12,7 @@ import org.mockito.Mockito; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; @@ -24,9 +29,29 @@ public AuthenticationService authenticationService() { return Mockito.mock(AuthenticationService.class); } + /** + * A spy rather than a mock, so behaviour that is only reachable through this service can still be + * exercised against the real database. Stubbing individual methods works exactly as before; + * anything left unstubbed now runs for real. + * + * @param userRepository repository of users + * @param userResearchGroupRoleRepository repository of the role a user holds in a research group + * @param researchGroupRepository repository of research groups + * @param imageService service used to resolve avatars + * @param passwordEncoder encoder used for locally managed passwords + * @return a spy wrapping a real {@link UserService} + */ @Bean - public UserService userService() { - return Mockito.mock(UserService.class); + public UserService userService( + UserRepository userRepository, + UserResearchGroupRoleRepository userResearchGroupRoleRepository, + ResearchGroupRepository researchGroupRepository, + ImageService imageService, + PasswordEncoder passwordEncoder + ) { + return Mockito.spy( + new UserService(userRepository, userResearchGroupRoleRepository, researchGroupRepository, imageService, passwordEncoder) + ); } @Bean diff --git a/src/test/java/de/tum/cit/aet/core/security/oauth2/OAuth2LoginSuccessHandlerTest.java b/src/test/java/de/tum/cit/aet/core/security/oauth2/OAuth2LoginSuccessHandlerTest.java index 83fd659d6f..8bc9c7d0be 100644 --- a/src/test/java/de/tum/cit/aet/core/security/oauth2/OAuth2LoginSuccessHandlerTest.java +++ b/src/test/java/de/tum/cit/aet/core/security/oauth2/OAuth2LoginSuccessHandlerTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -65,7 +66,7 @@ void provisionsUserAndSetsCookiesOnVerifiedGoogleLogin() throws Exception { ); User user = new User(); user.setUserId(UUID.randomUUID()); - when(userService.provisionExternalUser("applicant@gmail.com", "Ada", "Lovelace")).thenReturn(user); + doReturn(user).when(userService).provisionExternalUser("applicant@gmail.com", "Ada", "Lovelace"); when(appTokenService.issueFor(user)).thenReturn(new AuthResponseDTO("access", "refresh", 300, 2_592_000)); MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/src/test/java/de/tum/cit/aet/core/security/webauthn/WebAuthnLoginSuccessHandlerTest.java b/src/test/java/de/tum/cit/aet/core/security/webauthn/WebAuthnLoginSuccessHandlerTest.java index 26bfcc60af..a577334514 100644 --- a/src/test/java/de/tum/cit/aet/core/security/webauthn/WebAuthnLoginSuccessHandlerTest.java +++ b/src/test/java/de/tum/cit/aet/core/security/webauthn/WebAuthnLoginSuccessHandlerTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,7 +47,7 @@ void issuesAppSessionCookiesOnSuccessfulPasskeyAuthentication() throws Exception UUID userId = UUID.randomUUID(); User user = new User(); user.setUserId(userId); - when(userService.findById(userId.toString())).thenReturn(user); + doReturn(user).when(userService).findById(userId.toString()); when(appTokenService.issueFor(user)).thenReturn(new AuthResponseDTO("access", "refresh", 300, 2_592_000)); MockHttpServletRequest request = new MockHttpServletRequest(); @@ -61,8 +63,8 @@ void issuesAppSessionCookiesOnSuccessfulPasskeyAuthentication() throws Exception @Test void returnsUnauthorizedWhenNoUserMatchesPrincipal() throws Exception { - when(userService.findById("ghost")).thenThrow(new RuntimeException("not found")); - when(userService.findByEmail("ghost")).thenReturn(java.util.Optional.empty()); + doThrow(new RuntimeException("not found")).when(userService).findById("ghost"); + doReturn(java.util.Optional.empty()).when(userService).findByEmail("ghost"); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/AuthenticationResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/AuthenticationResourceTest.java index 2dc32ebc94..2e2ff03be9 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/AuthenticationResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/AuthenticationResourceTest.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.usermanagement.web; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; import de.tum.cit.aet.AbstractResourceTest; @@ -63,7 +64,7 @@ void setUp() { @Test void issuesSessionCookiesOnValidCredentials() { - when(userService.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + doReturn(Optional.of(user)).when(userService).findByEmail(EMAIL); MockHttpServletResponse response = api.postAndReturnResponse(LOGIN_PATH, new LoginRequestDTO(EMAIL, PASSWORD), 200); @@ -74,7 +75,7 @@ void issuesSessionCookiesOnValidCredentials() { @Test void rejectsWrongPassword() { - when(userService.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + doReturn(Optional.of(user)).when(userService).findByEmail(EMAIL); api.postAndRead(LOGIN_PATH, new LoginRequestDTO(EMAIL, "wrong-password"), Void.class, 401); } @@ -82,7 +83,7 @@ void rejectsWrongPassword() { @Test void rejectsUnverifiedEmailEvenWithCorrectPassword() { user.setEmailVerified(false); - when(userService.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + doReturn(Optional.of(user)).when(userService).findByEmail(EMAIL); api.postAndRead(LOGIN_PATH, new LoginRequestDTO(EMAIL, PASSWORD), Void.class, 401); } @@ -90,14 +91,14 @@ void rejectsUnverifiedEmailEvenWithCorrectPassword() { @Test void rejectsUserWithoutLocalPassword() { user.setPasswordHash(null); - when(userService.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + doReturn(Optional.of(user)).when(userService).findByEmail(EMAIL); api.postAndRead(LOGIN_PATH, new LoginRequestDTO(EMAIL, PASSWORD), Void.class, 401); } @Test void rejectsUnknownUser() { - when(userService.findByEmail("ghost@example.org")).thenReturn(Optional.empty()); + doReturn(Optional.empty()).when(userService).findByEmail("ghost@example.org"); api.postAndRead(LOGIN_PATH, new LoginRequestDTO("ghost@example.org", PASSWORD), Void.class, 401); } diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java index ca79681470..1b2628b716 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java @@ -1,7 +1,9 @@ package de.tum.cit.aet.usermanagement.web.rest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; @@ -12,6 +14,7 @@ import de.tum.cit.aet.usermanagement.constants.UserRole; import de.tum.cit.aet.usermanagement.domain.ResearchGroup; import de.tum.cit.aet.usermanagement.domain.User; +import de.tum.cit.aet.usermanagement.domain.UserResearchGroupRole; import de.tum.cit.aet.usermanagement.dto.AdminUserOverviewDTO; import de.tum.cit.aet.usermanagement.dto.CreateUserDTO; import de.tum.cit.aet.usermanagement.dto.ImportUserDTO; @@ -19,6 +22,7 @@ import de.tum.cit.aet.usermanagement.dto.UpdateUserDTO; import de.tum.cit.aet.usermanagement.repository.ResearchGroupRepository; import de.tum.cit.aet.usermanagement.repository.UserRepository; +import de.tum.cit.aet.usermanagement.repository.UserResearchGroupRoleRepository; import de.tum.cit.aet.usermanagement.service.KeycloakUserService; import de.tum.cit.aet.usermanagement.service.UserService; import de.tum.cit.aet.utility.DatabaseCleaner; @@ -49,6 +53,9 @@ class UserAdminResourceTest extends AbstractResourceTest { @Autowired ResearchGroupRepository researchGroupRepository; + @Autowired + UserResearchGroupRoleRepository userResearchGroupRoleRepository; + @Autowired UserService userService; @@ -162,9 +169,9 @@ void shouldRejectProfessor() { @Test void shouldCreateInternalUserWithLocalPasswordAndNotTouchKeycloak() { User created = UserTestData.savedUser(userRepository); - when(userService.findByEmail("new.user@tum.de")).thenReturn(Optional.empty()); - when(userService.provisionExternalUser("new.user@tum.de", "New", "User")).thenReturn(created); - when(userService.setLocalPassword(created.getUserId().toString(), "supersecure1")).thenReturn(true); + doReturn(Optional.empty()).when(userService).findByEmail("new.user@tum.de"); + doReturn(created).when(userService).provisionExternalUser("new.user@tum.de", "New", "User"); + doReturn(true).when(userService).setLocalPassword(created.getUserId().toString(), "supersecure1"); api .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) @@ -177,7 +184,7 @@ void shouldCreateInternalUserWithLocalPasswordAndNotTouchKeycloak() { @Test void shouldRejectWhenEmailAlreadyBelongsToAnExistingAccount() { - when(userService.findByEmail("new.user@tum.de")).thenReturn(Optional.of(professor)); + doReturn(Optional.of(professor)).when(userService).findByEmail("new.user@tum.de"); api .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) @@ -190,10 +197,10 @@ void shouldRejectWhenEmailAlreadyBelongsToAnExistingAccount() { @Test void shouldRejectWhenPasswordCannotBeSetForATumMember() { User created = UserTestData.savedUser(userRepository); - when(userService.findByEmail("new.user@tum.de")).thenReturn(Optional.empty()); - when(userService.provisionExternalUser("new.user@tum.de", "New", "User")).thenReturn(created); + doReturn(Optional.empty()).when(userService).findByEmail("new.user@tum.de"); + doReturn(created).when(userService).provisionExternalUser("new.user@tum.de", "New", "User"); // A TUM member is refused a local password by UserService. - when(userService.setLocalPassword(created.getUserId().toString(), "supersecure1")).thenReturn(false); + doReturn(false).when(userService).setLocalPassword(created.getUserId().toString(), "supersecure1"); api .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) @@ -228,7 +235,7 @@ void shouldImportTumMemberResolvedByUniversityId() { User imported = UserTestData.savedUser(userRepository); KeycloakUserDTO kcUser = new KeycloakUserDTO(imported.getUserId(), "kc.user", "Key", "Cloak", "key.cloak@tum.de", "ab12cde"); when(keycloakUserService.findUserByUniversityId("ab12cde")).thenReturn(Optional.of(kcUser)); - when(userService.upsertUser(imported.getUserId().toString(), "key.cloak@tum.de", "Key", "Cloak")).thenReturn(imported); + doReturn(imported).when(userService).upsertUser(imported.getUserId().toString(), "key.cloak@tum.de", "Key", "Cloak"); api .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) @@ -264,6 +271,14 @@ void shouldRejectProfessor() { } } + private static UserResearchGroupRole roleOf(User user, ResearchGroup group, UserRole role) { + UserResearchGroupRole mapping = new UserResearchGroupRole(); + mapping.setUser(user); + mapping.setResearchGroup(group); + mapping.setRole(role); + return mapping; + } + @Nested class UpdateUser { @@ -309,6 +324,44 @@ void shouldDelegateRoleAssignmentToUserService() { verify(userService).setPrimaryRole(target.getUserId(), UserRole.PROFESSOR, researchGroup.getResearchGroupId()); } + @Test + void shouldKeepMembershipOfOtherResearchGroupsWhenChangingARole() { + ResearchGroup otherGroup = ResearchGroupTestData.saved(researchGroupRepository); + User target = UserTestData.savedProfessor(userRepository, researchGroup); + userResearchGroupRoleRepository.save(roleOf(target, otherGroup, UserRole.EMPLOYEE)); + UpdateUserDTO dto = roleUpdate(UserRole.EMPLOYEE, researchGroup.getResearchGroupId()); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .putAndRead("/api/admin/users/" + target.getUserId(), dto, Void.class, 200); + + assertThat(userResearchGroupRoleRepository.findAllByUser(target)) + .extracting(role -> role.getResearchGroup().getResearchGroupId(), UserResearchGroupRole::getRole) + .containsExactlyInAnyOrder( + tuple(researchGroup.getResearchGroupId(), UserRole.EMPLOYEE), + tuple(otherGroup.getResearchGroupId(), UserRole.EMPLOYEE) + ); + } + + @Test + void shouldClearEveryResearchGroupWhenTheNewRoleBelongsToNone() { + ResearchGroup otherGroup = ResearchGroupTestData.saved(researchGroupRepository); + User target = UserTestData.savedProfessor(userRepository, researchGroup); + userResearchGroupRoleRepository.save(roleOf(target, otherGroup, UserRole.EMPLOYEE)); + UpdateUserDTO dto = roleUpdate(UserRole.APPLICANT, null); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .putAndRead("/api/admin/users/" + target.getUserId(), dto, Void.class, 200); + + assertThat(userResearchGroupRoleRepository.findAllByUser(target)) + .singleElement() + .satisfies(role -> { + assertThat(role.getRole()).isEqualTo(UserRole.APPLICANT); + assertThat(role.getResearchGroup()).isNull(); + }); + } + @Test void shouldRejectAdminChangingOwnRole() { UpdateUserDTO dto = roleUpdate(UserRole.APPLICANT, null); diff --git a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserResourceTest.java index 73e589a150..e136f39e71 100644 --- a/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserResourceTest.java +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserResourceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -161,7 +162,7 @@ class UpdatePassword { @Test void returnsNoContentWhenPasswordUpdateSucceeds() { String newPassword = "StrongPassword123!"; - when(userService.setLocalPassword(anyString(), eq(newPassword))).thenReturn(true); + doReturn(true).when(userService).setLocalPassword(anyString(), eq(newPassword)); UpdatePasswordDTO dto = new UpdatePasswordDTO(newPassword); @@ -175,7 +176,7 @@ void returnsNoContentWhenPasswordUpdateSucceeds() { @Test void returns400WhenPasswordUpdateFails() { String newPassword = "AnotherStrongPassword!"; - when(userService.setLocalPassword(anyString(), eq(newPassword))).thenReturn(false); + doReturn(false).when(userService).setLocalPassword(anyString(), eq(newPassword)); UpdatePasswordDTO dto = new UpdatePasswordDTO(newPassword); @@ -278,7 +279,7 @@ void shouldReturnAllProfessorsForAdmin() { secondProfessor.setUserId(UUID.randomUUID()); secondProfessor.setFirstName("Bob"); secondProfessor.setLastName("Doe"); - when(userService.getAllProfessors()).thenReturn(List.of(firstProfessor, secondProfessor)); + doReturn(List.of(firstProfessor, secondProfessor)).when(userService).getAllProfessors(); UUID adminUserId = UUID.randomUUID(); List result = api