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:
Konrad Neitzel 2026-02-22 17:55:45 +01:00
parent 1e1368e519
commit 3218cb1a2a
16 changed files with 1306 additions and 14 deletions

View File

@ -1,15 +1,18 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CreateUserRequest;
import de.neitzel.roleplay.common.UpdateUserRequest;
import de.neitzel.roleplay.common.UserSummary;
import de.neitzel.roleplay.data.UserEntity;
import de.neitzel.roleplay.data.UserRepository;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
@ -67,6 +70,107 @@ public class UserService {
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) {
return new UserSummary(entity.getId(), entity.getUsername(), entity.getRole());
}

View File

@ -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;
}
}

View File

@ -1,10 +1,12 @@
package de.neitzel.roleplay.common;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/**
* Classifies an AI character's response within a turn.
*/
@JsonDeserialize(using = ResponseTypeDeserializer.class)
public enum ResponseType {
/** Spoken dialogue. */

View File

@ -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");
}
};
}
}

View File

@ -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;
}
}

View File

@ -5,6 +5,7 @@ import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/**
* 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");
}
/**
* 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.
*

View File

@ -1,12 +1,17 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.UserService;
import de.neitzel.roleplay.common.ChangePasswordRequest;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
@ -16,8 +21,8 @@ import java.util.Map;
import java.util.stream.Collectors;
/**
* REST resource for auth-related endpoints: current user info 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.
* 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, /api/v1/auth/change-password, /api/v1/auth/logout.
*/
@Path("auth")
@Produces(MediaType.APPLICATION_JSON)
@ -27,14 +32,17 @@ public class AuthResource {
private final SecurityIdentity securityIdentity;
private final String sessionCookieName;
private final UserService userService;
@Inject
public AuthResource(
final SecurityIdentity securityIdentity,
@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.sessionCookieName = sessionCookieName;
this.userService = userService;
}
/**
@ -52,6 +60,29 @@ public class AuthResource {
"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.
* Returns 204 No Content. Requires authenticated user.

View File

@ -2,23 +2,30 @@ package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.UserService;
import de.neitzel.roleplay.common.CreateUserRequest;
import de.neitzel.roleplay.common.UpdateUserRequest;
import de.neitzel.roleplay.common.UserSummary;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
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("admin/users")
@ -28,10 +35,12 @@ import java.util.List;
public class UsersResource {
private final UserService userService;
private final SecurityIdentity securityIdentity;
@Inject
public UsersResource(final UserService userService) {
public UsersResource(final UserService userService, final SecurityIdentity securityIdentity) {
this.userService = userService;
this.securityIdentity = securityIdentity;
}
/**
@ -55,4 +64,39 @@ public class UsersResource {
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());
}
}
}

View File

@ -445,6 +445,31 @@ paths:
"401":
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:
get:
operationId: listUsers
@ -493,6 +518,72 @@ paths:
"403":
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:
parameters:
@ -519,6 +610,14 @@ components:
schema:
type: string
format: uuid
UserId:
name: userId
in: path
required: true
description: Unique identifier of the user (UUID).
schema:
type: string
format: uuid
schemas:
@ -1117,6 +1216,20 @@ components:
- username
- 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:
type: object
description: User without password (for list and create response).
@ -1163,6 +1276,26 @@ components:
- password
- 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 ────────────────────────────────────────────────────────────────
ErrorResponse:

View File

@ -6,6 +6,7 @@ import StartPage from './pages/StartPage'
import SessionPage from './pages/SessionPage'
import ScenariosPage from './pages/ScenariosPage'
import CharactersPage from './pages/CharactersPage'
import UsersPage from './pages/UsersPage'
import LoginPage from './pages/LoginPage'
/**
@ -28,6 +29,7 @@ export default function App() {
<Route path="/" element={<StartPage />} />
<Route path="/scenarios" element={<ScenariosPage />} />
<Route path="/characters" element={<CharactersPage />} />
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/session/:sessionId" element={<SessionPage />} />
</Route>
</Routes>

View File

@ -1,21 +1,116 @@
import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material'
import { Link as RouterLink, Outlet, useLocation } from 'react-router-dom'
import { Link } from '@mui/material'
import { useState } from 'react'
import {
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'
const authApi = new AuthApi(apiConfiguration)
/**
* 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() {
const location = useLocation()
const navigate = useNavigate()
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 = [
{ to: '/', label: 'Home' },
{ to: '/scenarios', label: 'Scenarios' },
{ 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static" elevation={0}>
@ -48,12 +143,31 @@ export default function AppLayout() {
))}
{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}
</Typography>
<Button color="inherit" size="small" onClick={() => logout()}>
Logout
</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>
@ -62,6 +176,52 @@ export default function AppLayout() {
<Box component="main" sx={{ flexGrow: 1, p: 2 }}>
<Outlet />
</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>
)
}

View 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>
</>
)
}

View File

@ -1,9 +1,11 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CreateUserRequest;
import de.neitzel.roleplay.common.UpdateUserRequest;
import de.neitzel.roleplay.common.UserSummary;
import de.neitzel.roleplay.data.UserEntity;
import de.neitzel.roleplay.data.UserRepository;
import io.quarkus.elytron.security.common.BcryptUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.assertThrows;
import static org.mockito.Mockito.verify;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
/**
@ -84,4 +86,111 @@ class UserServiceTest {
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());
}
}

View File

@ -139,4 +139,34 @@ class StateUpdateResponseDeserializationTest {
assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained());
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());
}
}

View File

@ -4,6 +4,8 @@ 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.Matchers.hasItem;
@ -73,4 +75,71 @@ class AuthIntegrationTest {
.then()
.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);
}
}

View File

@ -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);
}
}