Implement user management enhancements and password change functionality
- Add change password feature in UserService, allowing users to update their password after verifying the current one. - Introduce UpdateUserRequest model for updating user details, including username, password, and role. - Implement RESTful endpoints for changing passwords and updating user information in AuthResource and UsersResource. - Enhance OpenAPI specification to document new endpoints for password change and user updates. - Create frontend components for user management, including a dedicated UsersPage for admin users to manage user accounts. - Add unit tests for UserService to ensure correct behavior of password changes and user updates.
This commit is contained in:
parent
1e1368e519
commit
3218cb1a2a
@ -1,15 +1,18 @@
|
|||||||
package de.neitzel.roleplay.business;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
import de.neitzel.roleplay.common.CreateUserRequest;
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UpdateUserRequest;
|
||||||
import de.neitzel.roleplay.common.UserSummary;
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
import de.neitzel.roleplay.data.UserEntity;
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
import de.neitzel.roleplay.data.UserRepository;
|
import de.neitzel.roleplay.data.UserRepository;
|
||||||
|
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -67,6 +70,107 @@ public class UserService {
|
|||||||
return toSummary(entity);
|
return toSummary(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the password for the given user after verifying the current password.
|
||||||
|
*
|
||||||
|
* @param username login name of the user (must be the current user in practice)
|
||||||
|
* @param currentPassword plain-text current password (verified with bcrypt)
|
||||||
|
* @param newPassword plain-text new password (will be hashed)
|
||||||
|
* @throws IllegalArgumentException if user not found, current password wrong, or new password invalid
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(final String username, final String currentPassword, final String newPassword) {
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username is required");
|
||||||
|
}
|
||||||
|
if (currentPassword == null || currentPassword.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Current password is required");
|
||||||
|
}
|
||||||
|
if (newPassword == null || newPassword.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("New password is required");
|
||||||
|
}
|
||||||
|
UserEntity entity = userRepository.findByUsername(username);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new IllegalArgumentException("User not found: " + username);
|
||||||
|
}
|
||||||
|
if (!BcryptUtil.matches(currentPassword, entity.getPassword())) {
|
||||||
|
throw new IllegalArgumentException("Current password is incorrect");
|
||||||
|
}
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash(newPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing user by id. Username and role are required; password is optional.
|
||||||
|
*
|
||||||
|
* @param id user id
|
||||||
|
* @param request update request (username, optional password, role)
|
||||||
|
* @param currentUsername username of the caller (used to allow username uniqueness to exclude self)
|
||||||
|
* @return the updated user summary
|
||||||
|
* @throws IllegalArgumentException if validation fails or user not found
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public UserSummary updateUser(final UUID id, final UpdateUserRequest request, final String currentUsername) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new IllegalArgumentException("Update request is required");
|
||||||
|
}
|
||||||
|
String username = request.getUsername();
|
||||||
|
String password = request.getPassword();
|
||||||
|
String role = request.getRole();
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username is required");
|
||||||
|
}
|
||||||
|
if (role == null || role.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Role is required");
|
||||||
|
}
|
||||||
|
if (!"admin".equals(role) && !"user".equals(role)) {
|
||||||
|
throw new IllegalArgumentException("Role must be 'admin' or 'user'");
|
||||||
|
}
|
||||||
|
UserEntity entity = userRepository.findById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new IllegalArgumentException("User not found: " + id);
|
||||||
|
}
|
||||||
|
if (!entity.getUsername().equals(username)) {
|
||||||
|
UserEntity existing = userRepository.findByUsername(username);
|
||||||
|
if (existing != null) {
|
||||||
|
throw new IllegalArgumentException("Username already exists: " + username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setUsername(username);
|
||||||
|
entity.setRole(role);
|
||||||
|
if (password != null && !password.isBlank()) {
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash(password));
|
||||||
|
}
|
||||||
|
return toSummary(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user by id. Prevents deleting self and prevents deleting the last admin.
|
||||||
|
*
|
||||||
|
* @param id user id to delete
|
||||||
|
* @param currentUsername username of the caller (cannot delete self)
|
||||||
|
* @throws IllegalArgumentException if user not found, deleting self, or deleting last admin
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteUser(final UUID id, final String currentUsername) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new IllegalArgumentException("User id is required");
|
||||||
|
}
|
||||||
|
UserEntity entity = userRepository.findById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new IllegalArgumentException("User not found: " + id);
|
||||||
|
}
|
||||||
|
if (entity.getUsername().equals(currentUsername)) {
|
||||||
|
throw new IllegalArgumentException("Cannot delete your own user account");
|
||||||
|
}
|
||||||
|
if ("admin".equals(entity.getRole())) {
|
||||||
|
long adminCount = userRepository.count("role = ?1", "admin");
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new IllegalArgumentException("Cannot delete the last admin user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.delete();
|
||||||
|
}
|
||||||
|
|
||||||
private static UserSummary toSummary(final UserEntity entity) {
|
private static UserSummary toSummary(final UserEntity entity) {
|
||||||
return new UserSummary(entity.getId(), entity.getUsername(), entity.getRole());
|
return new UserSummary(entity.getId(), entity.getUsername(), entity.getRole());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for changing the current user's password.
|
||||||
|
*/
|
||||||
|
public final class ChangePasswordRequest {
|
||||||
|
|
||||||
|
private String currentPassword;
|
||||||
|
private String newPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public ChangePasswordRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a request with the given passwords.
|
||||||
|
*
|
||||||
|
* @param currentPassword current plain-text password (verified before change)
|
||||||
|
* @param newPassword new plain-text password (will be hashed)
|
||||||
|
*/
|
||||||
|
public ChangePasswordRequest(final String currentPassword, final String newPassword) {
|
||||||
|
this.currentPassword = currentPassword;
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current password (for verification).
|
||||||
|
*/
|
||||||
|
public String getCurrentPassword() {
|
||||||
|
return currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current password.
|
||||||
|
*/
|
||||||
|
public void setCurrentPassword(final String currentPassword) {
|
||||||
|
this.currentPassword = currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the new password (will be bcrypt-hashed on the server).
|
||||||
|
*/
|
||||||
|
public String getNewPassword() {
|
||||||
|
return newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the new password.
|
||||||
|
*/
|
||||||
|
public void setNewPassword(final String newPassword) {
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
package de.neitzel.roleplay.common;
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classifies an AI character's response within a turn.
|
* Classifies an AI character's response within a turn.
|
||||||
*/
|
*/
|
||||||
|
@JsonDeserialize(using = ResponseTypeDeserializer.class)
|
||||||
public enum ResponseType {
|
public enum ResponseType {
|
||||||
|
|
||||||
/** Spoken dialogue. */
|
/** Spoken dialogue. */
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||||
|
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes {@link ResponseType} from JSON strings. Accepts canonical values
|
||||||
|
* ({@code speech}, {@code action}, {@code reaction}) and combined values from
|
||||||
|
* Ollama (e.g. {@code speech|action}), mapping the latter to a single type.
|
||||||
|
*/
|
||||||
|
public class ResponseTypeDeserializer extends JsonDeserializer<ResponseType> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseType deserialize(final JsonParser p, final DeserializationContext ctxt)
|
||||||
|
throws IOException {
|
||||||
|
String value = p.getText();
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.strip().toLowerCase();
|
||||||
|
if ("speech|action".equals(normalized) || "action|speech".equals(normalized)) {
|
||||||
|
return ResponseType.SPEECH;
|
||||||
|
}
|
||||||
|
return switch (normalized) {
|
||||||
|
case "speech" -> ResponseType.SPEECH;
|
||||||
|
case "action" -> ResponseType.ACTION;
|
||||||
|
case "reaction" -> ResponseType.REACTION;
|
||||||
|
default -> {
|
||||||
|
ctxt.reportInputMismatch(ResponseType.class,
|
||||||
|
"Unknown ResponseType: '%s'; accepted: speech, action, reaction, speech|action", value);
|
||||||
|
throw new IllegalStateException("reportInputMismatch should have thrown");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for updating an existing user (admin-only). Username and role are required;
|
||||||
|
* password is optional (if absent, existing password is kept).
|
||||||
|
*/
|
||||||
|
public final class UpdateUserRequest {
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public UpdateUserRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a request with the given fields.
|
||||||
|
*
|
||||||
|
* @param username login name
|
||||||
|
* @param password optional new plain-text password (null or blank to keep current)
|
||||||
|
* @param role role (e.g. admin, user)
|
||||||
|
*/
|
||||||
|
public UpdateUserRequest(final String username, final String password, final String role) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the login name.
|
||||||
|
*/
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the login name.
|
||||||
|
*/
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the optional new password (null or blank means keep current).
|
||||||
|
*/
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the optional new password.
|
||||||
|
*/
|
||||||
|
public void setPassword(final String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the role (admin or user).
|
||||||
|
*/
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the role.
|
||||||
|
*/
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
|||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Panache repository for {@link UserEntity}. Used for admin user listing and lookup by username.
|
* Panache repository for {@link UserEntity}. Used for admin user listing and lookup by username.
|
||||||
@ -20,6 +21,16 @@ public class UserRepository implements PanacheRepository<UserEntity> {
|
|||||||
return list("ORDER BY username");
|
return list("ORDER BY username");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by id.
|
||||||
|
*
|
||||||
|
* @param id the user uuid
|
||||||
|
* @return the entity or null if not found
|
||||||
|
*/
|
||||||
|
public UserEntity findById(final UUID id) {
|
||||||
|
return find("id", id).firstResult();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a user by username.
|
* Finds a user by username.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
package de.neitzel.roleplay.fascade;
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.business.UserService;
|
||||||
|
import de.neitzel.roleplay.common.ChangePasswordRequest;
|
||||||
|
|
||||||
import io.quarkus.security.identity.SecurityIdentity;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.ForbiddenException;
|
import jakarta.ws.rs.ForbiddenException;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.BadRequestException;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.NewCookie;
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
@ -16,8 +21,8 @@ import java.util.Map;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST resource for auth-related endpoints: current user info and logout.
|
* REST resource for auth-related endpoints: current user info, change password, and logout.
|
||||||
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full paths are /api/v1/auth/me and /api/v1/auth/logout.
|
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full paths are /api/v1/auth/me, /api/v1/auth/change-password, /api/v1/auth/logout.
|
||||||
*/
|
*/
|
||||||
@Path("auth")
|
@Path("auth")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@ -27,14 +32,17 @@ public class AuthResource {
|
|||||||
|
|
||||||
private final SecurityIdentity securityIdentity;
|
private final SecurityIdentity securityIdentity;
|
||||||
private final String sessionCookieName;
|
private final String sessionCookieName;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public AuthResource(
|
public AuthResource(
|
||||||
final SecurityIdentity securityIdentity,
|
final SecurityIdentity securityIdentity,
|
||||||
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name", defaultValue = DEFAULT_COOKIE_NAME)
|
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name", defaultValue = DEFAULT_COOKIE_NAME)
|
||||||
final String sessionCookieName) {
|
final String sessionCookieName,
|
||||||
|
final UserService userService) {
|
||||||
this.securityIdentity = securityIdentity;
|
this.securityIdentity = securityIdentity;
|
||||||
this.sessionCookieName = sessionCookieName;
|
this.sessionCookieName = sessionCookieName;
|
||||||
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,6 +60,29 @@ public class AuthResource {
|
|||||||
"roles", securityIdentity.getRoles().stream().collect(Collectors.toList()));
|
"roles", securityIdentity.getRoles().stream().collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the current user's password. Requires current password verification.
|
||||||
|
* Returns 204 on success; 400 if current password wrong or validation fails.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("change-password")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
public Response changePassword(final ChangePasswordRequest request) {
|
||||||
|
if (securityIdentity.isAnonymous()) {
|
||||||
|
throw new ForbiddenException("Not authenticated");
|
||||||
|
}
|
||||||
|
if (request == null) {
|
||||||
|
throw new BadRequestException("Request body is required");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String username = securityIdentity.getPrincipal().getName();
|
||||||
|
userService.changePassword(username, request.getCurrentPassword(), request.getNewPassword());
|
||||||
|
return Response.noContent().build();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs out the current user by clearing the session cookie.
|
* Logs out the current user by clearing the session cookie.
|
||||||
* Returns 204 No Content. Requires authenticated user.
|
* Returns 204 No Content. Requires authenticated user.
|
||||||
|
|||||||
@ -2,23 +2,30 @@ package de.neitzel.roleplay.fascade;
|
|||||||
|
|
||||||
import de.neitzel.roleplay.business.UserService;
|
import de.neitzel.roleplay.business.UserService;
|
||||||
import de.neitzel.roleplay.common.CreateUserRequest;
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UpdateUserRequest;
|
||||||
import de.neitzel.roleplay.common.UserSummary;
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
|
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.BadRequestException;
|
import jakarta.ws.rs.BadRequestException;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST resource for admin-only user management: list users and create user.
|
* REST resource for admin-only user management: list, create, update, and delete users.
|
||||||
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full path is /api/v1/admin/users.
|
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full path is /api/v1/admin/users.
|
||||||
*/
|
*/
|
||||||
@Path("admin/users")
|
@Path("admin/users")
|
||||||
@ -28,10 +35,12 @@ import java.util.List;
|
|||||||
public class UsersResource {
|
public class UsersResource {
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public UsersResource(final UserService userService) {
|
public UsersResource(final UserService userService, final SecurityIdentity securityIdentity) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.securityIdentity = securityIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,4 +64,39 @@ public class UsersResource {
|
|||||||
throw new BadRequestException(e.getMessage());
|
throw new BadRequestException(e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing user by id. Admin only. Returns 200 with updated user summary.
|
||||||
|
*/
|
||||||
|
@PUT
|
||||||
|
@Path("{userId}")
|
||||||
|
public UserSummary updateUser(@PathParam("userId") final UUID userId, final UpdateUserRequest request) {
|
||||||
|
try {
|
||||||
|
String currentUsername = securityIdentity.getPrincipal().getName();
|
||||||
|
return userService.updateUser(userId, request, currentUsername);
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("not found")) {
|
||||||
|
throw new NotFoundException(e.getMessage());
|
||||||
|
}
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user by id. Admin only. Cannot delete self or the last admin. Returns 204.
|
||||||
|
*/
|
||||||
|
@DELETE
|
||||||
|
@Path("{userId}")
|
||||||
|
public Response deleteUser(@PathParam("userId") final UUID userId) {
|
||||||
|
try {
|
||||||
|
String currentUsername = securityIdentity.getPrincipal().getName();
|
||||||
|
userService.deleteUser(userId, currentUsername);
|
||||||
|
return Response.noContent().build();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("not found")) {
|
||||||
|
throw new NotFoundException(e.getMessage());
|
||||||
|
}
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -445,6 +445,31 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
description: Not authenticated.
|
description: Not authenticated.
|
||||||
|
|
||||||
|
/auth/change-password:
|
||||||
|
post:
|
||||||
|
operationId: changePassword
|
||||||
|
summary: Change password
|
||||||
|
description: Changes the current user's password. Requires current password verification.
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChangePasswordRequest'
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password changed.
|
||||||
|
"400":
|
||||||
|
description: Current password wrong or validation failed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
|
||||||
/admin/users:
|
/admin/users:
|
||||||
get:
|
get:
|
||||||
operationId: listUsers
|
operationId: listUsers
|
||||||
@ -493,6 +518,72 @@ paths:
|
|||||||
"403":
|
"403":
|
||||||
description: Forbidden (admin only).
|
description: Forbidden (admin only).
|
||||||
|
|
||||||
|
/admin/users/{userId}:
|
||||||
|
put:
|
||||||
|
operationId: updateUser
|
||||||
|
summary: Update user (admin only)
|
||||||
|
description: Updates an existing user by id. Username and role required; password optional (omit to keep current).
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/UserId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateUserRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: User updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserSummary'
|
||||||
|
"400":
|
||||||
|
description: Invalid request (e.g. username already exists).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
"404":
|
||||||
|
description: User not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
operationId: deleteUser
|
||||||
|
summary: Delete user (admin only)
|
||||||
|
description: Deletes a user by id. Cannot delete self or the last admin.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/UserId'
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: User deleted.
|
||||||
|
"400":
|
||||||
|
description: Cannot delete self or last admin.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
"404":
|
||||||
|
description: User not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
@ -519,6 +610,14 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
UserId:
|
||||||
|
name: userId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Unique identifier of the user (UUID).
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
|
||||||
@ -1117,6 +1216,20 @@ components:
|
|||||||
- username
|
- username
|
||||||
- roles
|
- roles
|
||||||
|
|
||||||
|
ChangePasswordRequest:
|
||||||
|
type: object
|
||||||
|
description: Request to change the current user's password.
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
description: Current plain-text password (verified before change).
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
description: New plain-text password (hashed on server).
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
- newPassword
|
||||||
|
|
||||||
UserSummary:
|
UserSummary:
|
||||||
type: object
|
type: object
|
||||||
description: User without password (for list and create response).
|
description: User without password (for list and create response).
|
||||||
@ -1163,6 +1276,26 @@ components:
|
|||||||
- password
|
- password
|
||||||
- role
|
- role
|
||||||
|
|
||||||
|
UpdateUserRequest:
|
||||||
|
type: object
|
||||||
|
description: Request to update an existing user (admin only). Password optional; omit to keep current.
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Optional new plain-text password (omit or blank to keep current).
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- admin
|
||||||
|
- user
|
||||||
|
description: Role to assign.
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- role
|
||||||
|
|
||||||
# ─── Error ────────────────────────────────────────────────────────────────
|
# ─── Error ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import StartPage from './pages/StartPage'
|
|||||||
import SessionPage from './pages/SessionPage'
|
import SessionPage from './pages/SessionPage'
|
||||||
import ScenariosPage from './pages/ScenariosPage'
|
import ScenariosPage from './pages/ScenariosPage'
|
||||||
import CharactersPage from './pages/CharactersPage'
|
import CharactersPage from './pages/CharactersPage'
|
||||||
|
import UsersPage from './pages/UsersPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,6 +29,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<StartPage />} />
|
<Route path="/" element={<StartPage />} />
|
||||||
<Route path="/scenarios" element={<ScenariosPage />} />
|
<Route path="/scenarios" element={<ScenariosPage />} />
|
||||||
<Route path="/characters" element={<CharactersPage />} />
|
<Route path="/characters" element={<CharactersPage />} />
|
||||||
|
<Route path="/admin/users" element={<UsersPage />} />
|
||||||
<Route path="/session/:sessionId" element={<SessionPage />} />
|
<Route path="/session/:sessionId" element={<SessionPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,21 +1,116 @@
|
|||||||
import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material'
|
import { useState } from 'react'
|
||||||
import { Link as RouterLink, Outlet, useLocation } from 'react-router-dom'
|
import {
|
||||||
import { Link } from '@mui/material'
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Link,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||||
|
import { Link as RouterLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { AuthApi } from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const authApi = new AuthApi(apiConfiguration)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
|
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
|
||||||
* Shows current username and Logout when authenticated.
|
* When authenticated, shows a user menu (username click) with Change Password, Edit users (admin only), and Logout.
|
||||||
*/
|
*/
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null)
|
||||||
|
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [changePasswordError, setChangePasswordError] = useState<string | null>(null)
|
||||||
|
const [changePasswordSubmitting, setChangePasswordSubmitting] = useState(false)
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Home' },
|
{ to: '/', label: 'Home' },
|
||||||
{ to: '/scenarios', label: 'Scenarios' },
|
{ to: '/scenarios', label: 'Scenarios' },
|
||||||
{ to: '/characters', label: 'Characters' },
|
{ to: '/characters', label: 'Characters' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const isAdmin = user?.roles?.includes('admin') ?? false
|
||||||
|
|
||||||
|
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setMenuAnchor(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUserMenuClose = () => {
|
||||||
|
setMenuAnchor(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePasswordClick = () => {
|
||||||
|
handleUserMenuClose()
|
||||||
|
setChangePasswordError(null)
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setChangePasswordOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePasswordClose = () => {
|
||||||
|
setChangePasswordOpen(false)
|
||||||
|
setChangePasswordError(null)
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePasswordSubmit = async () => {
|
||||||
|
setChangePasswordError(null)
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setChangePasswordError('New password and confirmation do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.length < 1) {
|
||||||
|
setChangePasswordError('New password is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChangePasswordSubmitting(true)
|
||||||
|
try {
|
||||||
|
await authApi.changePassword({
|
||||||
|
changePasswordRequest: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
handleChangePasswordClose()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
|
||||||
|
? (e.body as { message: string }).message
|
||||||
|
: 'Failed to change password'
|
||||||
|
setChangePasswordError(msg)
|
||||||
|
} finally {
|
||||||
|
setChangePasswordSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditUsersClick = () => {
|
||||||
|
handleUserMenuClose()
|
||||||
|
navigate('/admin/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogoutClick = () => {
|
||||||
|
handleUserMenuClose()
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
<AppBar position="static" elevation={0}>
|
<AppBar position="static" elevation={0}>
|
||||||
@ -48,12 +143,31 @@ export default function AppLayout() {
|
|||||||
))}
|
))}
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2" sx={{ px: 1 }}>
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
onClick={handleUserMenuOpen}
|
||||||
|
endIcon={<KeyboardArrowDownIcon />}
|
||||||
|
aria-label="User menu"
|
||||||
|
aria-controls={menuAnchor ? 'user-menu' : undefined}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={menuAnchor ? 'true' : 'false'}
|
||||||
|
>
|
||||||
{user.username}
|
{user.username}
|
||||||
</Typography>
|
|
||||||
<Button color="inherit" size="small" onClick={() => logout()}>
|
|
||||||
Logout
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Menu
|
||||||
|
id="user-menu"
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={handleUserMenuClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
MenuListProps={{ 'aria-labelledby': 'user-menu-button' }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleChangePasswordClick}>Change Password</MenuItem>
|
||||||
|
{isAdmin && <MenuItem onClick={handleEditUsersClick}>Edit users</MenuItem>}
|
||||||
|
<MenuItem onClick={handleLogoutClick}>Logout</MenuItem>
|
||||||
|
</Menu>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@ -62,6 +176,52 @@ export default function AppLayout() {
|
|||||||
<Box component="main" sx={{ flexGrow: 1, p: 2 }}>
|
<Box component="main" sx={{ flexGrow: 1, p: 2 }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Dialog open={changePasswordOpen} onClose={handleChangePasswordClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Change Password</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
id="current-password"
|
||||||
|
label="Current password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="new-password"
|
||||||
|
label="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="confirm-password"
|
||||||
|
label="Confirm new password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{changePasswordError && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
{changePasswordError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleChangePasswordClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleChangePasswordSubmit} variant="contained" disabled={changePasswordSubmitting}>
|
||||||
|
{changePasswordSubmitting ? 'Changing…' : 'Change password'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
280
src/main/web/src/pages/UsersPage.tsx
Normal file
280
src/main/web/src/pages/UsersPage.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest, UserSummary } from '../api/generated/index'
|
||||||
|
import { UsersApi } from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const usersApi = new UsersApi(apiConfiguration)
|
||||||
|
|
||||||
|
const ROLES = ['user', 'admin'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin users management page. Lists users with Add/Edit/Delete. Admin only (backend enforces 403).
|
||||||
|
*/
|
||||||
|
export default function UsersPage() {
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
const [users, setUsers] = useState<UserSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [formUsername, setFormUsername] = useState('')
|
||||||
|
const [formPassword, setFormPassword] = useState('')
|
||||||
|
const [formRole, setFormRole] = useState<string>('user')
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const list = await usersApi.listUsers()
|
||||||
|
setUsers(list)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load users')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [loadUsers])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setFormUsername('')
|
||||||
|
setFormPassword('')
|
||||||
|
setFormRole('user')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (u: UserSummary) => {
|
||||||
|
setEditingId(u.id)
|
||||||
|
setFormUsername(u.username ?? '')
|
||||||
|
setFormPassword('')
|
||||||
|
setFormRole(u.role ?? 'user')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formUsername.trim() || !formRole) return
|
||||||
|
if (!editingId && !formPassword.trim()) {
|
||||||
|
setError('Password is required for new user')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
const body: UpdateUserRequest = {
|
||||||
|
username: formUsername.trim(),
|
||||||
|
role: formRole as UpdateUserRequest['role'],
|
||||||
|
}
|
||||||
|
if (formPassword.trim()) {
|
||||||
|
body.password = formPassword
|
||||||
|
}
|
||||||
|
await usersApi.updateUser({ userId: editingId, updateUserRequest: body })
|
||||||
|
} else {
|
||||||
|
const body: CreateUserRequest = {
|
||||||
|
username: formUsername.trim(),
|
||||||
|
password: formPassword,
|
||||||
|
role: formRole as CreateUserRequest['role'],
|
||||||
|
}
|
||||||
|
await usersApi.createUser({ createUserRequest: body })
|
||||||
|
}
|
||||||
|
closeDialog()
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
|
||||||
|
? (e.body as { message: string }).message
|
||||||
|
: 'Failed to save user'
|
||||||
|
setError(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await usersApi.deleteUser({ userId: id })
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
|
||||||
|
? (e.body as { message: string }).message
|
||||||
|
: 'Failed to delete user'
|
||||||
|
setError(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentUser = (u: UserSummary) => currentUser?.username === u.username
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Typography variant="h4">Users</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<Alert severity="info">No users.</Alert>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Username</TableCell>
|
||||||
|
<TableCell>Role</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell>{u.username}</TableCell>
|
||||||
|
<TableCell>{u.role}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
onClick={() => openEdit(u)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={() => setDeleteConfirmId(u.id)}
|
||||||
|
disabled={isCurrentUser(u)}
|
||||||
|
title={isCurrentUser(u) ? 'Cannot delete your own account' : undefined}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{editingId ? 'Edit user' : 'Add user'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
value={formUsername}
|
||||||
|
onChange={(e) => setFormUsername(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={editingId ? 'New password (leave blank to keep current)' : 'Password'}
|
||||||
|
type="password"
|
||||||
|
value={formPassword}
|
||||||
|
onChange={(e) => setFormPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required={!editingId}
|
||||||
|
autoComplete={editingId ? 'new-password' : 'new-password'}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Role</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formRole}
|
||||||
|
label="Role"
|
||||||
|
onChange={(e) => setFormRole(e.target.value)}
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<MenuItem key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={saving || !formUsername.trim() || (!editingId && !formPassword.trim())}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteConfirmId)} onClose={() => setDeleteConfirmId(null)}>
|
||||||
|
<DialogTitle>Delete user?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
This cannot be undone. You cannot delete your own account.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
package de.neitzel.roleplay.business;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
import de.neitzel.roleplay.common.CreateUserRequest;
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UpdateUserRequest;
|
||||||
import de.neitzel.roleplay.common.UserSummary;
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
import de.neitzel.roleplay.data.UserEntity;
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
import de.neitzel.roleplay.data.UserRepository;
|
import de.neitzel.roleplay.data.UserRepository;
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@ -15,7 +17,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -84,4 +86,111 @@ class UserServiceTest {
|
|||||||
assertEquals("Username already exists: alice", e.getMessage());
|
assertEquals("Username already exists: alice", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordUpdatesPasswordWhenCurrentMatches() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.randomUUID());
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash("oldpass"));
|
||||||
|
entity.setRole("user");
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(entity);
|
||||||
|
|
||||||
|
userService.changePassword("alice", "oldpass", "newpass");
|
||||||
|
|
||||||
|
assertTrue(BcryptUtil.matches("newpass", entity.getPassword()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordThrowsWhenCurrentPasswordWrong() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash("oldpass"));
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(entity);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.changePassword("alice", "wrong", "newpass"));
|
||||||
|
assertEquals("Current password is incorrect", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordThrowsWhenUserNotFound() {
|
||||||
|
when(userRepository.findByUsername("nobody")).thenReturn(null);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.changePassword("nobody", "old", "new"));
|
||||||
|
assertEquals("User not found: nobody", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserSuccessWithoutNewPassword() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setRole("user");
|
||||||
|
when(userRepository.findById(UUID.fromString("22222222-2222-2222-2222-222222222222"))).thenReturn(entity);
|
||||||
|
// Same username as entity: uniqueness check not invoked
|
||||||
|
|
||||||
|
UpdateUserRequest request = new UpdateUserRequest("alice", null, "admin");
|
||||||
|
UserSummary result = userService.updateUser(entity.getId(), request, "admin");
|
||||||
|
|
||||||
|
assertEquals("alice", result.getUsername());
|
||||||
|
assertEquals("admin", result.getRole());
|
||||||
|
assertEquals("admin", entity.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserThrowsWhenUserNotFound() {
|
||||||
|
when(userRepository.findById(UUID.fromString("99999999-9999-9999-9999-999999999999"))).thenReturn(null);
|
||||||
|
UpdateUserRequest request = new UpdateUserRequest("bob", "secret", "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.updateUser(UUID.fromString("99999999-9999-9999-9999-999999999999"), request, "admin"));
|
||||||
|
assertEquals("User not found: 99999999-9999-9999-9999-999999999999", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserThrowsWhenUsernameTakenByOther() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setRole("user");
|
||||||
|
UserEntity other = new UserEntity();
|
||||||
|
other.setUsername("bob");
|
||||||
|
when(userRepository.findById(entity.getId())).thenReturn(entity);
|
||||||
|
when(userRepository.findByUsername("bob")).thenReturn(other);
|
||||||
|
|
||||||
|
UpdateUserRequest request = new UpdateUserRequest("bob", null, "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.updateUser(entity.getId(), request, "admin"));
|
||||||
|
assertEquals("Username already exists: bob", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserThrowsWhenDeletingSelf() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.randomUUID());
|
||||||
|
entity.setUsername("admin");
|
||||||
|
entity.setRole("admin");
|
||||||
|
when(userRepository.findById(entity.getId())).thenReturn(entity);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.deleteUser(entity.getId(), "admin"));
|
||||||
|
assertEquals("Cannot delete your own user account", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserThrowsWhenDeletingLastAdmin() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.randomUUID());
|
||||||
|
entity.setUsername("soleadmin");
|
||||||
|
entity.setRole("admin");
|
||||||
|
when(userRepository.findById(entity.getId())).thenReturn(entity);
|
||||||
|
when(userRepository.count("role = ?1", "admin")).thenReturn(1L);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.deleteUser(entity.getId(), "user"));
|
||||||
|
assertEquals("Cannot delete the last admin user", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,4 +139,34 @@ class StateUpdateResponseDeserializationTest {
|
|||||||
assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained());
|
assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained());
|
||||||
assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges());
|
assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that combined type "speech|action" from Ollama is accepted and
|
||||||
|
* mapped to {@link ResponseType#SPEECH}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void deserialisesSpeechOrActionAsSpeech() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"responses": [
|
||||||
|
{
|
||||||
|
"character_id": "narrator",
|
||||||
|
"type": "speech|action",
|
||||||
|
"content": "I will do it.",
|
||||||
|
"action": "nods firmly",
|
||||||
|
"mood_after": "determined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated_situation": null,
|
||||||
|
"updated_characters": [],
|
||||||
|
"suggestions": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
StateUpdateResponse result = mapper.readValue(json, StateUpdateResponse.class);
|
||||||
|
assertNotNull(result.getResponses());
|
||||||
|
assertEquals(1, result.getResponses().size());
|
||||||
|
assertEquals(ResponseType.SPEECH, result.getResponses().get(0).getType());
|
||||||
|
assertEquals("I will do it.", result.getResponses().get(0).getContent());
|
||||||
|
assertEquals("nods firmly", result.getResponses().get(0).getAction());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import io.quarkus.test.junit.QuarkusTest;
|
|||||||
import io.restassured.http.Cookies;
|
import io.restassured.http.Cookies;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static io.restassured.RestAssured.given;
|
||||||
import static org.hamcrest.CoreMatchers.is;
|
import static org.hamcrest.CoreMatchers.is;
|
||||||
import static org.hamcrest.Matchers.hasItem;
|
import static org.hamcrest.Matchers.hasItem;
|
||||||
@ -73,4 +75,71 @@ class AuthIntegrationTest {
|
|||||||
.then()
|
.then()
|
||||||
.statusCode(401);
|
.statusCode(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordSucceedsWithValidCurrentPassword() {
|
||||||
|
Cookies cookies = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "changeme", "newPassword", "newpass123"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
|
||||||
|
// Restore original password so other tests are not affected
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "newpass123", "newPassword", "changeme"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordReturns400WhenCurrentPasswordWrong() {
|
||||||
|
Cookies cookies = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "wrong", "newPassword", "newpass"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedChangePasswordReturns401() {
|
||||||
|
given()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "old", "newPassword", "new"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,151 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import io.restassured.http.Cookies;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.CoreMatchers.is;
|
||||||
|
import static org.hamcrest.CoreMatchers.notNullValue;
|
||||||
|
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for admin user management: list, create, update, delete users.
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
class UsersIntegrationTest {
|
||||||
|
|
||||||
|
private static Cookies loginAsAdmin() {
|
||||||
|
return given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedListUsersReturns401() {
|
||||||
|
given()
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listUsersAsAdminReturns200() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("size()", greaterThanOrEqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserAsAdminReturns201() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String username = "testuser_" + System.currentTimeMillis();
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username, "password", "secret", "role", "user"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("username", is(username))
|
||||||
|
.body("role", is("user"))
|
||||||
|
.body("id", notNullValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserAsAdminReturns200() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String username = "edituser_" + System.currentTimeMillis();
|
||||||
|
String id = given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username, "password", "secret", "role", "user"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path("id");
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username + "_updated", "role", "admin"))
|
||||||
|
.when()
|
||||||
|
.put("/api/v1/admin/users/" + id)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("username", is(username + "_updated"))
|
||||||
|
.body("role", is("admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserAsAdminReturns204() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String username = "deluser_" + System.currentTimeMillis();
|
||||||
|
String id = given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username, "password", "secret", "role", "user"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path("id");
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.delete("/api/v1/admin/users/" + id)
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserSelfReturns400() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String adminId = given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path("find { it.username == 'admin' }.id");
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.delete("/api/v1/admin/users/" + adminId)
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserNotFoundReturns404() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.delete("/api/v1/admin/users/00000000-0000-0000-0000-000000000000")
|
||||||
|
.then()
|
||||||
|
.statusCode(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user