diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 99364ae450..e557ff5b28 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -94,6 +94,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] @@ -3000,6 +3120,9 @@ components: items: {$ref: '#/components/schemas/KeycloakUserDTO'} minItems: 1 researchGroupId: {type: string, format: uuid} + role: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] required: [keycloakUsers] AdminApplicationOverviewDTO: type: object @@ -3064,6 +3187,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: @@ -3355,6 +3519,26 @@ 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} + primaryRole: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] + researchGroupId: {type: string, format: uuid} + selectedLanguage: {type: string} + universityId: {type: string} + website: {type: string} + required: [email, firstName, lastName, password] CreatedJobDTO: type: object properties: @@ -3555,6 +3739,11 @@ components: sizeBytes: {type: integer, format: int64} uploadedById: {type: string, format: uuid} url: {type: string} + ImportUserDTO: + type: object + properties: + universityId: {type: string, minLength: 1} + required: [universityId] InternalCommentDTO: type: object properties: @@ -3936,6 +4125,22 @@ components: sort: {$ref: '#/components/schemas/SortObject'} totalElements: {type: integer, format: int64} totalPages: {type: integer, format: int32} + 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: @@ -4357,6 +4562,25 @@ 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} + primaryRole: + type: string + enum: [APPLICANT, PROFESSOR, ADMIN, EMPLOYEE] + researchGroupId: {type: string, format: uuid} + selectedLanguage: {type: string} + universityId: {type: string} + website: {type: string} UpdateUserNameDTO: type: object properties: 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) { 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/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/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..0e19965f38 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/CreateUserDTO.java @@ -0,0 +1,31 @@ +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; + * optional fields are stored only in the local DB. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +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, + UserRole primaryRole, + UUID researchGroupId +) {} 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..9438dad286 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/ImportUserDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.usermanagement.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.NotBlank; + +/** + * Request body for importing an existing TUM member from Keycloak. + * + * @param universityId the university ID (LDAP_ID) of the Keycloak user to import + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ImportUserDTO(@NotBlank String universityId) {} 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..54e4aca118 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/UpdateUserDTO.java @@ -0,0 +1,28 @@ +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`, + * and `userId` are not updatable from this DTO. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +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, + UserRole primaryRole, + UUID researchGroupId +) {} 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 70bee88aee..60b6da9242 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 @@ -325,6 +325,61 @@ ORDER BY COALESCE(u.lastActivityAt, u.createdAt) ASC ) List findInactiveNonAdminUserIdsForWarning(@Param("warningDate") LocalDateTime warningDate); + /** + * Pages the ids of users matching the admin "Manage Users" filters. Optional filters for role and + * research-group; search matches firstName, lastName, email, or universityId. The per-row primary role + * and primary research group are assembled in the service from the eagerly-loaded role graph, since a + * user may now belong to multiple research groups. + * + * @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 user ids + */ + @Query( + """ + SELECT u.userId + 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 findUserIdsForAdmin( + @Param("roles") List roles, + @Param("researchGroupIds") List researchGroupIds, + @Param("searchQuery") String searchQuery, + Pageable pageable + ); + + /** + * Loads the given users with their research-group roles and each role's research group eagerly fetched, + * so the admin overview can derive the primary role and primary group without lazy loading (OSIV is off). + * + * @param userIds the user ids to load + * @return the matching users with roles and groups initialised + */ + @Query( + """ + SELECT DISTINCT u FROM User u + LEFT JOIN FETCH u.researchGroupRoles rgr + LEFT JOIN FETCH rgr.researchGroup + WHERE u.userId IN :userIds + """ + ) + List findUsersWithRolesAndGroupsByIds(@Param("userIds") List userIds); + /** * Finds every user holding a PROFESSOR role in any research group, with roles eagerly loaded. * 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 f91901df4c..519b49d6be 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; @@ -636,19 +637,30 @@ public void createEmployeeResearchGroupRequest(EmployeeResearchGroupRequestDTO r } /** - * Adds multiple members to a research group. Verifies that the current user is an admin or a - * member of the target research group before proceeding. For each provided Keycloak user, - * ensures a matching local user exists (creating one if necessary) and assigns them to the - * target group; an email notification is sent only when the user was newly created or their - * research-group assignment changed. + * Adds multiple members to a research group. + *

+ * 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 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 list of {@link KeycloakUserDTO} representing the users to add - * @param researchGroupId target research group; when null the current user's group is used - * (only valid for 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); @@ -685,7 +697,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/service/UserAdminService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java new file mode 100644 index 0000000000..cc38699a64 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/service/UserAdminService.java @@ -0,0 +1,380 @@ +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.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.UserRepository; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +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); + + // 1) Page the matching user ids (sorting is on User columns only, so it can be applied here). + Page idPage = userRepository.findUserIdsForAdmin( + (roles == null || roles.isEmpty()) ? null : roles, + (researchGroupIds == null || researchGroupIds.isEmpty()) ? null : researchGroupIds, + StringUtil.normalizeSearchQuery(searchQuery), + pageable + ); + List ids = idPage.getContent(); + if (ids.isEmpty()) { + return new PageImpl<>(List.of(), pageable, idPage.getTotalElements()); + } + + // 2) Fetch the full role/group graph, then build each row in the paged order (the fetch query + // does not preserve it) — a user may belong to several groups, hence the two-query approach. + Map usersById = userRepository + .findUsersWithRolesAndGroupsByIds(ids) + .stream() + .collect(Collectors.toMap(User::getUserId, Function.identity(), (first, second) -> first)); + List content = ids + .stream() + .map(usersById::get) + .filter(Objects::nonNull) + .map(UserAdminService::toOverviewDTO) + .toList(); + return new PageImpl<>(content, pageable, idPage.getTotalElements()); + } + + /** + * Maps a user (with roles and groups initialised) to an {@link AdminUserOverviewDTO}, deriving the + * highest-privilege role and the primary (first PROFESSOR/EMPLOYEE) research group. + * + * @param user the user to map; its research-group roles and groups must be initialised + * @return the overview row + */ + private static AdminUserOverviewDTO toOverviewDTO(User user) { + List roles = user.getResearchGroupRoles() == null ? List.of() : List.copyOf(user.getResearchGroupRoles()); + UserRole primaryRole = roles + .stream() + .map(UserResearchGroupRole::getRole) + .max(Comparator.comparingInt(UserAdminService::priority)) + .orElse(null); + ResearchGroup researchGroup = roles + .stream() + .filter(r -> r.getRole() == UserRole.PROFESSOR || r.getRole() == UserRole.EMPLOYEE) + .map(UserResearchGroupRole::getResearchGroup) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + return new AdminUserOverviewDTO( + user.getUserId(), + user.getFirstName(), + user.getLastName(), + user.getEmail(), + user.getAvatar(), + user.getUniversityId(), + primaryRole, + researchGroup == null ? null : researchGroup.getResearchGroupId(), + researchGroup == null ? null : researchGroup.getName(), + user.getLastActivityAt() + ); + } + + /** + * 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 + .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); + // Derive the primary research group from the group-bound (PROFESSOR/EMPLOYEE) roles; the legacy + // User.researchGroup column was replaced by the user_research_group_roles join table. + ResearchGroup researchGroup = + user.getResearchGroupRoles() == null + ? null + : user + .getResearchGroupRoles() + .stream() + .filter(r -> r.getRole() == UserRole.PROFESSOR || r.getRole() == UserRole.EMPLOYEE) + .map(r -> r.getResearchGroup()) + .filter(rg -> rg != null) + .findFirst() + .orElse(null); + UUID rgId = researchGroup == null ? null : researchGroup.getResearchGroupId(); + String rgName = researchGroup == null ? null : researchGroup.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 internally managed user with a local password, then applies any optional + * DB-only fields. Admin-created users are always internal: TUM members authenticate through + * Keycloak and are never written to it from here. + * + * @param dto the create-user payload + * @return the new user's UUID + * @throws OperationNotAllowedException if the email already belongs to an existing account + */ + @Transactional + public UUID create(CreateUserDTO dto) { + // 1) Refuse to take over an existing account. provisionExternalUser resolves by email, so + // without this an admin could set a password on (and verify the email of) somebody else's + // account — including a TUM member, who must never gain password login. + String normalizedEmail = StringUtil.normalize(dto.email(), true); + if (userService.findByEmail(normalizedEmail).isPresent()) { + throw new OperationNotAllowedException("A user with email " + normalizedEmail + " already exists."); + } + // 2) Provision the local user (fresh app-owned id, email marked verified). + User user = userService.provisionExternalUser(normalizedEmail, dto.firstName(), dto.lastName()); + UUID userId = user.getUserId(); + // 3) Apply DB-only optional fields first, so a supplied universityId is visible to the + // password guard below rather than being written after it. + applyOptionalCreateFields(userId, dto); + // 4) Store the initial password as a BCrypt hash. Rejected for TUM members, who must be + // imported from Keycloak instead of created with a local password. + if (!userService.setLocalPassword(userId.toString(), dto.password())) { + throw new OperationNotAllowedException("Cannot set a local password for a TUM member. Import the user from Keycloak instead."); + } + // 5) Apply primary role assignment when supplied. + if (dto.primaryRole() != null) { + userService.setPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); + } + return userId; + } + + /** + * Imports an existing TUM member from Keycloak into the local DB by their university ID. + * The identity is re-resolved from Keycloak rather than taken from the request, so a picked + * entry cannot be used to fabricate a user. Nothing is written back to Keycloak. + * + * @param dto the import payload containing the university ID of the user to import + * @return the imported user's UUID + * @throws EntityNotFoundException if no Keycloak user exists with the given university ID + */ + public UUID importFromKeycloak(ImportUserDTO dto) { + KeycloakUserDTO kcUser = keycloakUserService + .findUserByUniversityId(dto.universityId()) + .orElseThrow(() -> EntityNotFoundException.forId("KeycloakUser", dto.universityId())); + User user = userService.upsertUser(kcUser.id().toString(), kcUser.email(), kcUser.firstName(), kcUser.lastName()); + // Carry the university id over so the imported row is recognisable as a TUM member. + if (user.getUniversityId() == null && kcUser.universityId() != null) { + user.setUniversityId(kcUser.universityId()); + userRepository.save(user); + } + return user.getUserId(); + } + + /** + * 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 + */ + public void update(UUID userId, UpdateUserDTO dto) { + 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()); + } + 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); + if (dto.primaryRole() != null) { + userService.setPrimaryRole(userId, dto.primaryRole(), dto.researchGroupId()); + } + } + + /** + * Deletes a user by anonymising their local-DB references. An admin cannot delete their own + * account. TUM members are not removed from Keycloak: their identity is owned there and only + * the local record is dropped, so a subsequent login re-provisions a fresh row. + * + * @param userId the user ID to delete + * @throws OperationNotAllowedException if the caller targets their own account + */ + public void delete(UUID userId) { + UUID currentUserId = currentUserService.getUserId(); + if (userId.equals(currentUserId)) { + throw new OperationNotAllowedException("Admins cannot delete their own account."); + } + 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/main/java/de/tum/cit/aet/usermanagement/service/UserService.java b/src/main/java/de/tum/cit/aet/usermanagement/service/UserService.java index 5cb1770d29..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 @@ -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; @@ -32,6 +35,7 @@ public class UserService { private final UserRepository userRepository; private final UserResearchGroupRoleRepository userResearchGroupRoleRepository; + private final ResearchGroupRepository researchGroupRepository; private final ImageService imageService; private final PasswordEncoder passwordEncoder; private static final Duration LAST_ACTIVITY_UPDATE_THRESHOLD = Duration.ofHours(24); @@ -39,11 +43,13 @@ public class UserService { public UserService( UserRepository userRepository, UserResearchGroupRoleRepository userResearchGroupRoleRepository, + ResearchGroupRepository researchGroupRepository, ImageService imageService, PasswordEncoder passwordEncoder ) { this.userRepository = userRepository; this.userResearchGroupRoleRepository = userResearchGroupRoleRepository; + this.researchGroupRepository = researchGroupRepository; this.imageService = imageService; this.passwordEncoder = passwordEncoder; } @@ -73,16 +79,22 @@ public User provisionExternalUser(String email, String firstName, String lastNam /** * Sets (or replaces) the local password for the given user, storing only a BCrypt hash. + * TUM members are rejected: they authenticate through Keycloak, and granting them a password + * would open a second, app-local way into the same account. * * @param userId the user id (UUID string) * @param rawPassword the new plaintext password - * @return {@code true} if the password was updated, {@code false} if the input was blank + * @return {@code true} if the password was updated, {@code false} if the input was blank or the + * user is a TUM member */ public boolean setLocalPassword(String userId, String rawPassword) { if (rawPassword == null || rawPassword.isBlank()) { return false; } User user = userRepository.findById(UUID.fromString(userId)).orElseThrow(() -> EntityNotFoundException.forId("User", userId)); + if (user.getUniversityId() != null) { + return false; + } user.setPasswordHash(passwordEncoder.encode(rawPassword)); userRepository.save(user); return true; @@ -254,6 +266,62 @@ public PageResponseDTO getAvailableUsersForResearchGroup(PageDTO p return new PageResponseDTO<>(userDTOs, userIdsPage.getTotalElements()); } + /** + * 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 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)); + + 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); + } + + /** + * @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); + return mapping; + } + /** * Ensures the user has at least one role by assigning {@link UserRole APPLICANT} when none are present. * 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 12fed3d350..476fb3185a 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 @@ -317,7 +317,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/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..8b17d1b15f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/web/UserAdminResource.java @@ -0,0 +1,139 @@ +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 TUM member from Keycloak into the local DB + * by their university ID. + * + * @param dto the import payload containing the university 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 universityId={}", dto.universityId()); + 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}} : 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)} + */ + @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/main/webapp/app/app.routes.ts b/src/main/webapp/app/app.routes.ts index 95e6333bd9..55f293e052 100644 --- a/src/main/webapp/app/app.routes.ts +++ b/src/main/webapp/app/app.routes.ts @@ -306,6 +306,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/generated/.openapi-generator/FILES b/src/main/webapp/app/generated/.openapi-generator/FILES index db7d82624d..0d60a54a8b 100644 --- a/src/main/webapp/app/generated/.openapi-generator/FILES +++ b/src/main/webapp/app/generated/.openapi-generator/FILES @@ -51,6 +51,8 @@ api/research-group-resource-resources.ts api/school-resource-api.ts api/school-resource-resources.ts api/site-setting-resource-api.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 @@ -65,6 +67,8 @@ model/add-members-to-research-group-dto.ts model/admin-application-overview-dto.ts model/admin-created-job-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/ai-usage-analytics-dto.ts model/ai-usage-cost-summary-dto.ts @@ -94,6 +98,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 @@ -113,6 +118,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 @@ -135,6 +141,7 @@ model/otp-config.ts model/overall-recommendation.ts model/page-admin-application-overview-dto.ts model/page-admin-created-job-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 @@ -183,6 +190,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..ea8ea2667b --- /dev/null +++ b/src/main/webapp/app/generated/model/create-user-dto.ts @@ -0,0 +1,39 @@ +/** + * 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 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/import-user-dto.ts b/src/main/webapp/app/generated/model/import-user-dto.ts new file mode 100644 index 0000000000..4ea73982d4 --- /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 universityId: 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..d97ed144de --- /dev/null +++ b/src/main/webapp/app/generated/model/update-user-dto.ts @@ -0,0 +1,39 @@ +/** + * 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 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 new file mode 100644 index 0000000000..6fb0b120fb --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.html @@ -0,0 +1,373 @@ +

+ + +
+ + @if (mode() === 'edit') { + + } +
+ +
+ @if (mode() === 'import') { + + + +
+

+ + + + @if (selectedImportUser(); as selectedUser) { +
+
{{ selectedUser.firstName }} {{ selectedUser.lastName }}
+
{{ selectedUser.email }}
+
{{ selectedUser.universityId }}
+
+ +
+
+ } @else { +
+ @if (isLoadingImportUsers()) { +
+ +
+ } + + @if (showImportSearchMinLengthHint()) { +
+ +
+ } + + @if (showNoImportResults()) { +
+ +
+ } + + @if (showImportCandidatesList()) { + @for (user of importCandidates(); track user.id) { +
+
+ {{ user.firstName ? user.firstName.charAt(0) : '' }}{{ user.lastName ? user.lastName.charAt(0) : '' }} +
+
+
{{ user.firstName }} {{ user.lastName }}
+
{{ user.email }}
+
{{ user.universityId }}
+
+
+ } + + @if (hasMoreImportCandidates()) { +
+ +
+ } + } +
+ } + + @if (showImportSelectionError()) { +

+ } +
+ } @else { + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + @if (mode() === 'create') { +
+
+ +
+
+
+ } + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + + + + +
+
+ +
+
+ @if (requiresResearchGroup()) { + + } +
+
+ + @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..217e1c5100 --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-user-form.component.ts @@ -0,0 +1,643 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +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'; +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 { UserResourceApi } from 'app/generated/api/user-resource-api'; +import { AdminUserDetailDTO } from 'app/generated/model/admin-user-detail-dto'; +import { CreateUserDTO, CreateUserDTOPrimaryRoleEnum, CreateUserDTOPrimaryRoleEnumValues } from 'app/generated/model/create-user-dto'; +import { ImportUserDTO } from 'app/generated/model/import-user-dto'; +import { KeycloakUserDTO } from 'app/generated/model/keycloak-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'; +import { ConfirmDialog } from 'app/shared/components/atoms/confirm-dialog/confirm-dialog'; +import { DatePickerComponent } from 'app/shared/components/atoms/datepicker/datepicker.component'; +import { ProgressSpinnerComponent } from 'app/shared/components/atoms/progress-spinner/progress-spinner.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 { SearchFilterSortBar } from 'app/shared/components/molecules/search-filter-sort-bar/search-filter-sort-bar'; +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,}$/; + +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: + * - `/manage-users/create` becomes create + * - `/manage-users/create?mode=import` becomes import + * - `/manage-users/:userId` becomes edit + */ +@Component({ + selector: 'jhi-manage-user-form', + imports: [ + BackButtonComponent, + ButtonComponent, + ConfirmDialog, + DatePickerComponent, + DividerModule, + LocalizedDatePipe, + ProgressSpinnerComponent, + ReactiveFormsModule, + SearchFilterSortBar, + 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' }, + ]; + 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); + + // ------- Keycloak user picker (import mode) ------- + readonly MIN_IMPORT_SEARCH_LENGTH = 3; + readonly IMPORT_USERS_PAGE_SIZE = 25; + readonly importSearchQuery = signal(''); + /** Candidates that can actually be imported, i.e. that carry a university id. */ + readonly importCandidates = signal([]); + readonly importTotalCount = signal(0); + readonly selectedImportUser = signal(undefined); + readonly isLoadingImportUsers = signal(false); + readonly showImportSelectionError = signal(false); + + readonly isImportSearchQueryLongEnough = computed(() => this.importSearchQuery().trim().length >= this.MIN_IMPORT_SEARCH_LENGTH); + readonly hasMoreImportCandidates = computed(() => this.importLoadedCount() < this.importTotalCount()); + readonly showImportSearchMinLengthHint = computed( + () => !this.isLoadingImportUsers() && this.importSearchQuery().trim().length > 0 && !this.isImportSearchQueryLongEnough(), + ); + readonly showNoImportResults = computed( + () => !this.isLoadingImportUsers() && this.isImportSearchQueryLongEnough() && this.importCandidates().length === 0, + ); + readonly showImportCandidatesList = computed(() => !this.isLoadingImportUsers() && this.importCandidates().length > 0); + + /** True while import mode has no importable user selected yet. */ + readonly importSelectionInvalid = computed(() => this.mode() === 'import' && this.selectedImportUser() === undefined); + + // ------- 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: [''], + 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); + private readonly researchGroupApi = inject(ResearchGroupResourceApi); + private readonly userApi = inject(UserResourceApi); + + // ------- Keycloak user picker internals ------- + // Delay before showing the loading spinner so fast queries don't flicker. + private readonly IMPORT_LOADER_DELAY_MS = 250; + private readonly importLoadedCount = signal(0); + private importCurrentPage = 0; + private importLoaderTimeout: number | undefined = undefined; + private latestImportSearchRequestId = 0; + + // 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); + } + }); + + constructor() { + void this.loadResearchGroups(); + } + + /** + * Submit the form for the current mode. Routes to the matching API call. + */ + async onSubmit(): Promise { + if (this.importSelectionInvalid()) { + this.showImportSelectionError.set(true); + return; + } + if (this.form.invalid || this.roleSelectionInvalid()) { + 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), + }), + }); + } + } + + 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); + } + + 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 ?? ''); + } + + /** + * Run a Keycloak user search for the import picker. Queries shorter than the + * minimum length reset the candidate list without hitting the server. + * + * @param searchQuery the raw query typed into the search bar + */ + async onImportUserSearch(searchQuery: string): Promise { + if (this.mode() !== 'import') { + return; + } + + const requestId = ++this.latestImportSearchRequestId; + this.importSearchQuery.set(searchQuery); + const trimmedQuery = searchQuery.trim(); + + this.clearImportLoaderTimeout(); + + if (trimmedQuery.length < this.MIN_IMPORT_SEARCH_LENGTH) { + this.resetImportCandidates(); + this.isLoadingImportUsers.set(false); + return; + } + + await this.loadImportUserPage(trimmedQuery, 0, false, requestId); + } + + /** + * Append the next page of Keycloak users to the import picker. + */ + async onLoadMoreImportUsers(): Promise { + if (this.mode() !== 'import' || this.isLoadingImportUsers() || !this.hasMoreImportCandidates()) { + return; + } + + const trimmedQuery = this.importSearchQuery().trim(); + if (trimmedQuery.length < this.MIN_IMPORT_SEARCH_LENGTH) { + return; + } + + const requestId = ++this.latestImportSearchRequestId; + await this.loadImportUserPage(trimmedQuery, this.importCurrentPage + 1, true, requestId); + } + + /** + * Select a Keycloak user as the import target. Users without a university id + * cannot be imported and are ignored. + * + * @param user the candidate picked from the result list + */ + selectImportUser(user: KeycloakUserDTO): void { + if (!this.isImportable(user)) { + return; + } + this.selectedImportUser.set(user); + this.showImportSelectionError.set(false); + } + + /** + * Drop the current import selection and re-run the last search. + */ + clearSelectedImportUser(): void { + this.selectedImportUser.set(undefined); + void this.onImportUserSearch(this.importSearchQuery()); + } + + /** + * 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 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)]); + firstNameControl.setValidators([Validators.required]); + lastNameControl.setValidators([Validators.required]); + emailControl.setValidators([Validators.required, Validators.email]); + } else if (mode === 'import') { + passwordControl.clearValidators(); + firstNameControl.clearValidators(); + lastNameControl.clearValidators(); + emailControl.clearValidators(); + } else { + // edit + passwordControl.clearValidators(); + firstNameControl.setValidators([Validators.required]); + lastNameControl.setValidators([Validators.required]); + emailControl.setValidators([Validators.required, Validators.email]); + } + + passwordControl.updateValueAndValidity({ emitEvent: false }); + firstNameControl.updateValueAndValidity({ emitEvent: false }); + lastNameControl.updateValueAndValidity({ emitEvent: false }); + emailControl.updateValueAndValidity({ emitEvent: false }); + } + + /** + * Fetch one page of importable Keycloak users and apply it to the picker state. + * + * @param searchQuery the trimmed search query + * @param page zero-based page index to fetch + * @param append true to append to the current candidates, false to replace them + * @param requestId monotonic id used to drop stale responses + */ + private async loadImportUserPage(searchQuery: string, page: number, append: boolean, requestId: number): Promise { + this.importLoaderTimeout = window.setTimeout(() => { + if (requestId === this.latestImportSearchRequestId) { + this.isLoadingImportUsers.set(true); + } + }, this.IMPORT_LOADER_DELAY_MS); + + try { + // No research group id: this admin flow has no target group. + const response = await firstValueFrom(this.userApi.getAvailableUsersForResearchGroup(this.IMPORT_USERS_PAGE_SIZE, page, searchQuery)); + if (requestId !== this.latestImportSearchRequestId) { + return; + } + + const pageContent = response.content ?? []; + // Only TUM users carry a university id; local users cannot be imported. + const importable = pageContent.filter(user => this.isImportable(user)); + + this.importCandidates.set(append ? this.importCandidates().concat(importable) : importable); + this.importLoadedCount.set(append ? this.importLoadedCount() + pageContent.length : pageContent.length); + this.importTotalCount.set(response.totalElements ?? this.importLoadedCount()); + this.importCurrentPage = page; + } catch { + if (requestId === this.latestImportSearchRequestId) { + this.toastService.showErrorKey(`${TRANSLATION_KEY}.errors.loadKeycloakUsers`); + } + } finally { + if (requestId === this.latestImportSearchRequestId) { + this.clearImportLoaderTimeout(); + this.isLoadingImportUsers.set(false); + } + } + } + + private isImportable(user: KeycloakUserDTO): boolean { + const universityId = user.universityId; + return universityId !== undefined && universityId.trim() !== ''; + } + + private resetImportCandidates(): void { + this.importCandidates.set([]); + this.importLoadedCount.set(0); + this.importTotalCount.set(0); + this.importCurrentPage = 0; + } + + private clearImportLoaderTimeout(): void { + if (this.importLoaderTimeout !== undefined) { + clearTimeout(this.importLoaderTimeout); + this.importLoaderTimeout = undefined; + } + } + + 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.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 = { + 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()), + primaryRole: this.selectedRole()?.value as CreateUserDTOPrimaryRoleEnum | undefined, + researchGroupId: this.requiresResearchGroup() ? this.selectOptionValue(this.selectedResearchGroup()) : undefined, + }; + + 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 universityId = this.selectedImportUser()?.universityId; + if (universityId === undefined || universityId === '') { + this.showImportSelectionError.set(true); + return; + } + const dto: ImportUserDTO = { universityId }; + 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 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, + 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()), + primaryRole: sendRole ? selectedRole : undefined, + researchGroupId: sendRole && this.requiresResearchGroup() ? selectedGroupId : undefined, + }; + + 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..92089da612 --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.html @@ -0,0 +1,93 @@ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + {{ row.fullName }} +
+
+ + + @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..f1902eb88d --- /dev/null +++ b/src/main/webapp/app/usermanagement/manage-users/manage-users-page.component.ts @@ -0,0 +1,331 @@ +import { Component, TemplateRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Router } 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; +} + +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 + * delete users. + */ +@Component({ + selector: 'jhi-manage-users-page', + imports: [ + ButtonComponent, + ConfirmDialog, + DynamicTableComponent, + LocalizedDatePipe, + 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: readonly 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 ?? ''); + + 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; + + // ------- 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(); + } + + 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: UserRow): 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); + } + } + + 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/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 645bfb1e2a..f520b5fc76 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); @@ -177,6 +187,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; @@ -185,7 +207,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/global.json b/src/main/webapp/i18n/de/global.json index 1b77c1cd74..809b4f5b75 100644 --- a/src/main/webapp/i18n/de/global.json +++ b/src/main/webapp/i18n/de/global.json @@ -45,6 +45,11 @@ "exports": "{siteName} - Admin-Exporte", "systemSettings": "{siteName} - Systemeinstellungen" }, + "user": { + "manageUsers": "{siteName} - Nutzende verwalten", + "createUser": "{siteName} - Nutzende anlegen", + "userDetail": "{siteName} - Nutzendendetails" + }, "about-us": "{siteName} - Über uns", "interview": { "overview": "{siteName} - Interviewübersicht", 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/research-group.json b/src/main/webapp/i18n/de/research-group.json index 74b448bd7c..858359b9ae 100644 --- a/src/main/webapp/i18n/de/research-group.json +++ b/src/main/webapp/i18n/de/research-group.json @@ -116,6 +116,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/de/usermanagement.json b/src/main/webapp/i18n/de/usermanagement.json new file mode 100644 index 0000000000..a5a51066d4 --- /dev/null +++ b/src/main/webapp/i18n/de/usermanagement.json @@ -0,0 +1,104 @@ +{ + "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", + "roleSection": "Rolle", + "statusSection": "Status", + "fields": { + "firstName": "Vorname", + "lastName": "Nachname", + "email": "E-Mail", + "password": "Passwort", + "universityId": "Universitäts-ID", + "userId": "Nutzenden-UUID", + "phoneNumber": "Telefonnummer", + "gender": "Geschlecht", + "nationality": "Nationalität", + "birthday": "Geburtsdatum", + "website": "Website", + "linkedinUrl": "LinkedIn", + "selectedLanguage": "Sprache", + "primaryRole": "Rolle", + "researchGroup": "Forschungsgruppe", + "avatar": "Avatar-URL", + "createdAt": "Erstellt", + "lastActivityAt": "Letzte Aktivität", + "aiFeaturesEnabled": "KI-Funktionen aktiviert" + }, + "passwordRequirements": "Mindestens 8 Zeichen mit Groß- und Kleinbuchstaben sowie einer Ziffer.", + "userSelect": { + "hint": "Suche die TUM-Nutzenden, die importiert werden sollen. Nur Nutzende mit einer Universitäts-ID können importiert werden.", + "searchPlaceholder": "Nach Name oder E-Mail suchen...", + "singleEntity": "Nutzende", + "multipleEntities": "Nutzende", + "emptySearch": "Bitte gib mindestens {minLength} Zeichen ein, um zu suchen.", + "noResults": "Keine importierbaren Nutzenden gefunden.", + "requiredError": "Bitte wähle Nutzende zum Importieren aus.", + "loadMore": "Mehr laden", + "changeButton": "Auswahl ändern" + }, + "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": "" }, + "loadKeycloakUsers": { "summary": "Keycloak-Nutzende 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 eaf157c8e9..3533bae8b3 100644 --- a/src/main/webapp/i18n/en/global.json +++ b/src/main/webapp/i18n/en/global.json @@ -45,6 +45,11 @@ "exports": "{siteName} - Admin Exports", "systemSettings": "{siteName} - System Settings" }, + "user": { + "manageUsers": "{siteName} - Manage Users", + "createUser": "{siteName} - Create User", + "userDetail": "{siteName} - User Detail" + }, "about-us": "{siteName} - About Us", "interview": { "overview": "{siteName} - Interview Overview", 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/research-group.json b/src/main/webapp/i18n/en/research-group.json index c7fcc29782..3f4d4e1728 100644 --- a/src/main/webapp/i18n/en/research-group.json +++ b/src/main/webapp/i18n/en/research-group.json @@ -116,6 +116,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/main/webapp/i18n/en/usermanagement.json b/src/main/webapp/i18n/en/usermanagement.json new file mode 100644 index 0000000000..06fb8c3683 --- /dev/null +++ b/src/main/webapp/i18n/en/usermanagement.json @@ -0,0 +1,104 @@ +{ + "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", + "roleSection": "Role", + "statusSection": "Status", + "fields": { + "firstName": "First Name", + "lastName": "Last Name", + "email": "Email", + "password": "Password", + "universityId": "University ID", + "userId": "User UUID", + "phoneNumber": "Phone Number", + "gender": "Gender", + "nationality": "Nationality", + "birthday": "Date of Birth", + "website": "Website", + "linkedinUrl": "LinkedIn", + "selectedLanguage": "Language", + "primaryRole": "Role", + "researchGroup": "Research Group", + "avatar": "Avatar URL", + "createdAt": "Created", + "lastActivityAt": "Last Activity", + "aiFeaturesEnabled": "AI Features Enabled" + }, + "passwordRequirements": "At least 8 characters with uppercase, lowercase, and a digit.", + "userSelect": { + "hint": "Search for the TUM user you want to import. Only users with a university ID can be imported.", + "searchPlaceholder": "Search by name or email...", + "singleEntity": "User", + "multipleEntities": "Users", + "emptySearch": "Please enter at least {minLength} characters to search.", + "noResults": "No importable users found.", + "requiredError": "Please select a user to import.", + "loadMore": "Load more", + "changeButton": "Change selection" + }, + "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": "" }, + "loadKeycloakUsers": { "summary": "Could not load Keycloak users", "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}" + } + } +} 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/ResearchGroupResourceTest.java b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/ResearchGroupResourceTest.java index e3f36d9b0a..8415667694 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; @@ -1037,7 +1038,7 @@ class AddMembersToResearchGroup { void shouldAddMembersToResearchGroupAsProfessor() { 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")) @@ -1051,7 +1052,7 @@ void shouldAddMembersToResearchGroupAsProfessor() { void shouldAddMembersToResearchGroupAsAdmin() { 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 @@ -1066,7 +1067,7 @@ void shouldAddMembersToResearchGroupAsAdmin() { void shouldCreateUserWhenAddMembersToResearchGroupWithNonExistentUser() { 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")) @@ -1082,7 +1083,7 @@ void shouldThrowAccessDeniedWhenAddMembersToResearchGroupWithNonExistentGroup() 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. @@ -1103,7 +1104,11 @@ void shouldAddMultipleMembersToResearchGroupAsAdmin() { 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 @@ -1115,5 +1120,49 @@ void shouldAddMultipleMembersToResearchGroupAsAdmin() { assertThat(userResearchGroupRoleRepository.findByUserAndResearchGroup(ua, researchGroup)).isPresent(); assertThat(userResearchGroupRoleRepository.findByUserAndResearchGroup(ub, researchGroup)).isPresent(); } + + @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()) + ); + } } } 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..1b2628b716 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/usermanagement/web/rest/UserAdminResourceTest.java @@ -0,0 +1,390 @@ +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; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +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.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.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; +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.Optional; +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 + UserResearchGroupRoleRepository userResearchGroupRoleRepository; + + @Autowired + UserService userService; + + @Autowired + KeycloakUserService keycloakUserService; + + @Autowired + DatabaseCleaner databaseCleaner; + + @Autowired + MvcTestClient api; + + ResearchGroup researchGroup; + User adminUser; + User professor; + + @BeforeEach + void setup() { + databaseCleaner.clean(); + reset(userService, keycloakUserService); + 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() { + api + .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) + .postAndRead("/api/admin/users", createPayload(), Void.class, 403); + } + + @Test + void shouldCreateInternalUserWithLocalPasswordAndNotTouchKeycloak() { + User created = UserTestData.savedUser(userRepository); + 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")) + .postAndRead("/api/admin/users", createPayload(), Void.class, 201); + + verify(userService).provisionExternalUser("new.user@tum.de", "New", "User"); + verify(userService).setLocalPassword(created.getUserId().toString(), "supersecure1"); + verifyNoInteractions(keycloakUserService); + } + + @Test + void shouldRejectWhenEmailAlreadyBelongsToAnExistingAccount() { + doReturn(Optional.of(professor)).when(userService).findByEmail("new.user@tum.de"); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .postAndRead("/api/admin/users", createPayload(), Void.class, 400); + + verify(userService, never()).provisionExternalUser(anyString(), anyString(), anyString()); + verify(userService, never()).setLocalPassword(anyString(), anyString()); + } + + @Test + void shouldRejectWhenPasswordCannotBeSetForATumMember() { + User created = UserTestData.savedUser(userRepository); + 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. + doReturn(false).when(userService).setLocalPassword(created.getUserId().toString(), "supersecure1"); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .postAndRead("/api/admin/users", createPayload(), Void.class, 400); + } + + private CreateUserDTO createPayload() { + return new CreateUserDTO( + "New", + "User", + "new.user@tum.de", + "supersecure1", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + } + } + + @Nested + class ImportUser { + + @Test + 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)); + doReturn(imported).when(userService).upsertUser(imported.getUserId().toString(), "key.cloak@tum.de", "Key", "Cloak"); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .postAndRead("/api/admin/users/import", new ImportUserDTO("ab12cde"), Void.class, 201); + + verify(keycloakUserService).findUserByUniversityId("ab12cde"); + assertThat(userRepository.findById(imported.getUserId()).orElseThrow().getUniversityId()).isEqualTo("ab12cde"); + } + + @Test + void shouldReturn404WhenUniversityIdIsUnknownInKeycloak() { + when(keycloakUserService.findUserByUniversityId("zz99zzz")).thenReturn(Optional.empty()); + + api + .with(JwtPostProcessors.jwtUser(adminUser.getUserId(), "ROLE_ADMIN")) + .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); + } + + @Test + void shouldRejectProfessor() { + api + .with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR")) + .postAndRead("/api/admin/users/import", new ImportUserDTO("ab12cde"), Void.class, 403); + } + } + + 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 { + + @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 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); + + 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); + } + } +} 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 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..dfbc78b193 --- /dev/null +++ b/src/test/webapp/app/usermanagement/manage-users/manage-user-form.component.spec.ts @@ -0,0 +1,533 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 { AdminUserDetailDTO } from 'app/generated/model/admin-user-detail-dto'; +import { KeycloakUserDTO } from 'app/generated/model/keycloak-user-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'; +import { + createResearchGroupResourceApiMock, + provideResearchGroupResourceApiMock, + ResearchGroupResourceApiMock, +} from 'util/research-group-resource-api.service.mock'; +import { createUserResourceApiMock, provideUserResourceApiMock, UserResourceApiMock } from 'util/user-resource-api.service.mock'; + +describe('ManageUserFormComponent', () => { + let mockUserAdminApi: UserAdminResourceApiMock; + let mockResearchGroupApi: ResearchGroupResourceApiMock; + let mockUserApi: UserResourceApiMock; + let mockToastService: ToastServiceMock; + let mockRouter: RouterMock; + + const tumUser: KeycloakUserDTO = { + id: 'kc-1', + username: 'ga12abc', + firstName: 'Bob', + lastName: 'Builder', + email: 'bob@tum.de', + universityId: 'ga12abc', + }; + + const localUser: KeycloakUserDTO = { + id: 'kc-2', + username: 'local@example.com', + firstName: 'Local', + lastName: 'Only', + email: 'local@example.com', + }; + + 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', + }; + + 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({ 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, + }), + ); + + mockUserApi = createUserResourceApiMock(); + mockUserApi.getAvailableUsersForResearchGroup.mockReturnValue(of({ content: [], totalElements: 0 })); + + mockToastService = createToastServiceMock(); + mockRouter = createRouterMock(); + + await TestBed.configureTestingModule({ + imports: [ManageUserFormComponent], + providers: [ + provideUserAdminResourceApiMock(mockUserAdminApi), + provideResearchGroupResourceApiMock(mockResearchGroupApi), + provideUserResourceApiMock(mockUserApi), + provideToastServiceMock(mockToastService), + provideTranslateMock(createTranslateServiceMock()), + provideFontAwesomeTesting(), + provideRouterMock(mockRouter), + provideActivatedRouteMock(createActivatedRouteMock(params, query)), + provideLocationMock(), + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ManageUserFormComponent); + fixture.detectChanges(); + 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'); + 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'))); + mockResearchGroupApi = createResearchGroupResourceApiMock(); + mockResearchGroupApi.getResearchGroupsForAdmin.mockReturnValue(of({ content: [], totalElements: 0 })); + mockToastService = createToastServiceMock(); + mockRouter = createRouterMock(); + + await TestBed.configureTestingModule({ + imports: [ManageUserFormComponent], + providers: [ + provideUserAdminResourceApiMock(mockUserAdminApi), + provideResearchGroupResourceApiMock(mockResearchGroupApi), + provideUserResourceApiMock(), + provideToastServiceMock(mockToastService), + provideTranslateMock(createTranslateServiceMock()), + provideFontAwesomeTesting(), + provideRouterMock(mockRouter), + provideActivatedRouteMock(createActivatedRouteMock({ userId: 'user-1' })), + provideLocationMock(), + ], + }).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); + + 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 clear all identity validators in import mode and require a picked user instead', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + const form = component.form; + + form.patchValue({ firstName: '', lastName: '', email: '', password: '' }); + + expect(form.controls.firstName.errors).toBeNull(); + expect(form.controls.lastName.errors).toBeNull(); + expect(form.controls.email.errors).toBeNull(); + expect(form.controls.password.errors).toBeNull(); + expect(component.importSelectionInvalid()).toBe(true); + + component.selectImportUser(tumUser); + expect(component.importSelectionInvalid()).toBe(false); + }); + + it('should not require password in edit mode and not gate on the import picker', async () => { + const fixture = await setupComponent({ userId: 'user-1' }); + const component = fixture.componentInstance; + const form = component.form; + + form.patchValue({ firstName: '', lastName: '', password: '' }); + expect(form.controls.firstName.hasError('required')).toBe(true); + expect(form.controls.lastName.hasError('required')).toBe(true); + expect(form.controls.password.errors).toBeNull(); + expect(component.importSelectionInvalid()).toBe(false); + }); + }); + + 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).toHaveBeenCalledOnce(); + 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).toHaveBeenCalledOnce(); + }); + + it('should call importUser with the selected university id and navigate to the new user page', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + + component.selectImportUser(tumUser); + + await component.onSubmit(); + + expect(mockUserAdminApi.importUser).toHaveBeenCalledWith({ universityId: 'ga12abc' }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/manage-users', 'new-user-2']); + expect(mockToastService.showSuccess).toHaveBeenCalledOnce(); + }); + + it('should skip the import call and surface an error when no user is selected', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + + await component.onSubmit(); + + expect(mockUserAdminApi.importUser).not.toHaveBeenCalled(); + expect(component.showImportSelectionError()).toBe(true); + }); + + 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).toHaveBeenCalledOnce(); + 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).toHaveBeenCalledOnce(); + }); + + it('should mark form as touched and skip the API call when invalid', async () => { + const fixture = await setupComponent(); + const component = fixture.componentInstance; + + 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).toHaveBeenCalledOnce(); + 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.selectImportUser(tumUser); + + await component.onSubmit(); + + expect(mockToastService.showError).toHaveBeenCalledOnce(); + 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).toHaveBeenCalledOnce(); + }); + }); + + 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).toHaveBeenCalledOnce(); + }); + + 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'))); + mockRouter.navigate.mockClear(); + + await component.onConfirmDelete(); + + expect(mockToastService.showError).toHaveBeenCalledOnce(); + 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('Keycloak user picker (import mode)', () => { + it('should load candidates without a research group id when the query is long enough', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + mockUserApi.getAvailableUsersForResearchGroup.mockReturnValue(of({ content: [tumUser], totalElements: 1 })); + + await component.onImportUserSearch('bob'); + + expect(mockUserApi.getAvailableUsersForResearchGroup).toHaveBeenCalledWith(25, 0, 'bob'); + expect(component.importCandidates()).toEqual([tumUser]); + expect(component.isLoadingImportUsers()).toBe(false); + }); + + it('should drop candidates without a university id', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + mockUserApi.getAvailableUsersForResearchGroup.mockReturnValue(of({ content: [tumUser, localUser], totalElements: 2 })); + + await component.onImportUserSearch('user'); + + expect(component.importCandidates()).toEqual([tumUser]); + }); + + it('should ignore a selection of a user without a university id', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + + component.selectImportUser(localUser); + + expect(component.selectedImportUser()).toBeUndefined(); + expect(component.importSelectionInvalid()).toBe(true); + }); + + it('should not query the server when the search query is too short', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + + await component.onImportUserSearch('bo'); + + expect(mockUserApi.getAvailableUsersForResearchGroup).not.toHaveBeenCalled(); + expect(component.importCandidates()).toEqual([]); + expect(component.showImportSearchMinLengthHint()).toBe(true); + }); + + it('should append the next page when loading more candidates', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + 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'); + expect(component.hasMoreImportCandidates()).toBe(true); + + mockUserApi.getAvailableUsersForResearchGroup.mockReturnValue(of({ content: [secondUser], totalElements: 2 })); + await component.onLoadMoreImportUsers(); + + expect(mockUserApi.getAvailableUsersForResearchGroup).toHaveBeenLastCalledWith(25, 1, 'bob'); + expect(component.importCandidates()).toEqual([tumUser, secondUser]); + expect(component.hasMoreImportCandidates()).toBe(false); + }); + + it('should clear the selection and reset the candidate list', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + + component.selectImportUser(tumUser); + component.clearSelectedImportUser(); + + expect(component.selectedImportUser()).toBeUndefined(); + expect(component.importCandidates()).toEqual([]); + }); + + it('should toast when the candidate search fails', async () => { + const fixture = await setupComponent({}, { mode: 'import' }); + const component = fixture.componentInstance; + mockUserApi.getAvailableUsersForResearchGroup.mockReturnValue(throwError(() => new Error('boom'))); + + await component.onImportUserSearch('bob'); + + expect(mockToastService.showErrorKey).toHaveBeenCalledWith('manageUsersPage.errors.loadKeycloakUsers'); + expect(component.isLoadingImportUsers()).toBe(false); + }); + + it('should not search outside import mode', async () => { + const fixture = await setupComponent(); + const component = fixture.componentInstance; + + await component.onImportUserSearch('bob'); + + expect(mockUserApi.getAvailableUsersForResearchGroup).not.toHaveBeenCalled(); + }); + }); + + 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..edadd97c9a --- /dev/null +++ b/src/test/webapp/app/usermanagement/manage-users/manage-users-page.component.spec.ts @@ -0,0 +1,337 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 { 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 { provideAccountServiceMock, createAccountServiceMock, AccountServiceMock } from 'util/account.service.mock'; +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: RouterMock; + let mockAccountService: AccountServiceMock; + + 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 = createRouterMock(); + mockAccountService = createAccountServiceMock(); + mockAccountService.user.set({ id: 'admin-id', name: 'Bob Burton', email: 'bob@example.com', authorities: [] }); + + await TestBed.configureTestingModule({ + imports: [ManageUsersPageComponent], + providers: [ + provideUserAdminResourceApiMock(mockUserAdminApi), + provideResearchGroupResourceApiMock(mockResearchGroupApi), + provideToastServiceMock(mockToastService), + provideTranslateMock(createTranslateServiceMock()), + provideFontAwesomeTesting(), + provideRouterMock(mockRouter), + provideActivatedRouteMock(createActivatedRouteMock()), + provideAccountServiceMock(mockAccountService), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ManageUsersPageComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Initialization', () => { + 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 () => { + 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', 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(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', 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 call deleteUser on confirmed delete', async () => { + 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(); + + expect(mockUserAdminApi.deleteUser).toHaveBeenCalledWith('user-1'); + expect(mockToastService.showSuccess).toHaveBeenCalledOnce(); + 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 () => { + 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(aliceRow); + + await component.onConfirmDelete(); + + expect(mockToastService.showError).toHaveBeenCalledOnce(); + 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('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); + + 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); + }); + }); +}); 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 20463cad2d..6db4473082 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 @@ -301,6 +301,7 @@ describe('ResearchGroupAddMembersComponent', () => { expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ keycloakUsers: [mockUser1, mockUser2], researchGroupId: 'research-group-1', + role: 'EMPLOYEE', }); expect(mockToastService.showSuccessKey).toHaveBeenCalledWith('researchGroup.members.toastMessages.addMembersSuccess'); expect(mockDialogRef.close).toHaveBeenCalledWith(true); @@ -341,6 +342,66 @@ describe('ResearchGroupAddMembersComponent', () => { await component.onAddMembers(); expect(mockToastService.showErrorKey).toHaveBeenCalledWith('researchGroup.members.toastMessages.addMembersFailedInvalidUniversityId'); + expect(mockDialogRef.close).toHaveBeenCalledWith(false); + }); + + it('should add members with undefined researchGroupId', async () => { + mockDialogConfig.data = undefined; + component.toggleUserSelection(mockUser1); + mockResearchGroupService.addMembersToResearchGroup.mockReturnValue(of(void 0)); + + await component.onAddMembers(); + + expect(mockResearchGroupService.addMembersToResearchGroup).toHaveBeenCalledWith({ + keycloakUsers: [mockUser1], + researchGroupId: undefined, + role: 'EMPLOYEE', + }); + }); + }); + + 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); + expect(component.selectedUserCount()).toBe(1); + + mockUserService.getAvailableUsersForResearchGroup.mockReturnValue(of(mockPageResponse)); + component.onPageChange({ first: 10, rows: 10 }); + await Promise.resolve(); + + expect(component.selectedUserCount()).toBe(1); + expect(component.isUserSelected(mockUser1)).toBe(true); + }); + + it('should maintain selection state across search queries', async () => { + component.toggleUserSelection(mockUser1); + component.toggleUserSelection(mockUser2); + expect(component.selectedUserCount()).toBe(2); + + mockUserService.getAvailableUsersForResearchGroup.mockReturnValue(of(mockPageResponse)); + component.onSearch('new-search'); + await Promise.resolve(); + + expect(component.selectedUserCount()).toBe(2); + expect(component.isUserSelected(mockUser1)).toBe(true); + expect(component.isUserSelected(mockUser2)).toBe(true); }); }); }); 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 }; +} diff --git a/src/test/webapp/util/user-resource-api.service.mock.ts b/src/test/webapp/util/user-resource-api.service.mock.ts index 0abbd5a67a..86c9511c1e 100644 --- a/src/test/webapp/util/user-resource-api.service.mock.ts +++ b/src/test/webapp/util/user-resource-api.service.mock.ts @@ -8,6 +8,7 @@ export type UserResourceApiMock = { updatePassword: ReturnType; getAiConsent: ReturnType; getAllProfessors: ReturnType; + getAvailableUsersForResearchGroup: ReturnType; }; export function createUserResourceApiMock(): UserResourceApiMock { @@ -17,6 +18,7 @@ export function createUserResourceApiMock(): UserResourceApiMock { updatePassword: vi.fn(), getAiConsent: vi.fn(), getAllProfessors: vi.fn(), + getAvailableUsersForResearchGroup: vi.fn(), }; }