diff --git a/src/main/java/de/neitzel/roleplay/business/UserService.java b/src/main/java/de/neitzel/roleplay/business/UserService.java index 547ff10..fe1ed81 100644 --- a/src/main/java/de/neitzel/roleplay/business/UserService.java +++ b/src/main/java/de/neitzel/roleplay/business/UserService.java @@ -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()); } diff --git a/src/main/java/de/neitzel/roleplay/common/ChangePasswordRequest.java b/src/main/java/de/neitzel/roleplay/common/ChangePasswordRequest.java new file mode 100644 index 0000000..41f085a --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/ChangePasswordRequest.java @@ -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; + } +} diff --git a/src/main/java/de/neitzel/roleplay/common/ResponseType.java b/src/main/java/de/neitzel/roleplay/common/ResponseType.java index c49e4bb..fb384b4 100644 --- a/src/main/java/de/neitzel/roleplay/common/ResponseType.java +++ b/src/main/java/de/neitzel/roleplay/common/ResponseType.java @@ -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. */ diff --git a/src/main/java/de/neitzel/roleplay/common/ResponseTypeDeserializer.java b/src/main/java/de/neitzel/roleplay/common/ResponseTypeDeserializer.java new file mode 100644 index 0000000..cca3dcd --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/ResponseTypeDeserializer.java @@ -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 { + + @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"); + } + }; + } +} diff --git a/src/main/java/de/neitzel/roleplay/common/UpdateUserRequest.java b/src/main/java/de/neitzel/roleplay/common/UpdateUserRequest.java new file mode 100644 index 0000000..8dad3be --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/UpdateUserRequest.java @@ -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; + } +} diff --git a/src/main/java/de/neitzel/roleplay/data/UserRepository.java b/src/main/java/de/neitzel/roleplay/data/UserRepository.java index 9230ff2..a31fb84 100644 --- a/src/main/java/de/neitzel/roleplay/data/UserRepository.java +++ b/src/main/java/de/neitzel/roleplay/data/UserRepository.java @@ -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 { 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. * diff --git a/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java b/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java index 08a6292..7f990d8 100644 --- a/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java +++ b/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java @@ -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. diff --git a/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java b/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java index e79656d..d94cbc7 100644 --- a/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java +++ b/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java @@ -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()); + } + } } diff --git a/src/main/resources/openapi-roleplay-public-v1.yml b/src/main/resources/openapi-roleplay-public-v1.yml index 1cf8b35..d517277 100644 --- a/src/main/resources/openapi-roleplay-public-v1.yml +++ b/src/main/resources/openapi-roleplay-public-v1.yml @@ -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: diff --git a/src/main/web/src/App.tsx b/src/main/web/src/App.tsx index 4324924..611a4b6 100644 --- a/src/main/web/src/App.tsx +++ b/src/main/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/src/main/web/src/components/AppLayout.tsx b/src/main/web/src/components/AppLayout.tsx index 91bcfcd..1900a64 100644 --- a/src/main/web/src/components/AppLayout.tsx +++ b/src/main/web/src/components/AppLayout.tsx @@ -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) + const [changePasswordOpen, setChangePasswordOpen] = useState(false) + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [changePasswordError, setChangePasswordError] = useState(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) => { + 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 ( @@ -48,12 +143,31 @@ export default function AppLayout() { ))} {user && ( <> - + + + Change Password + {isAdmin && Edit users} + Logout + )} @@ -62,6 +176,52 @@ export default function AppLayout() { + + + Change Password + + + setCurrentPassword(e.target.value)} + fullWidth + autoComplete="current-password" + /> + setNewPassword(e.target.value)} + fullWidth + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + fullWidth + autoComplete="new-password" + /> + {changePasswordError && ( + + {changePasswordError} + + )} + + + + + + + ) } diff --git a/src/main/web/src/pages/UsersPage.tsx b/src/main/web/src/pages/UsersPage.tsx new file mode 100644 index 0000000..6919ccf --- /dev/null +++ b/src/main/web/src/pages/UsersPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [saving, setSaving] = useState(false) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + const [formUsername, setFormUsername] = useState('') + const [formPassword, setFormPassword] = useState('') + const [formRole, setFormRole] = useState('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 ( + + + + ) + } + + return ( + <> + + Users + + + + {error && ( + setError(null)} sx={{ mb: 2 }}> + {error} + + )} + + {users.length === 0 ? ( + No users. + ) : ( + + + + Username + Role + Actions + + + + {users.map((u) => ( + + {u.username} + {u.role} + + + + + + ))} + +
+ )} + + + {editingId ? 'Edit user' : 'Add user'} + + + setFormUsername(e.target.value)} + fullWidth + required + autoComplete="username" + /> + setFormPassword(e.target.value)} + fullWidth + required={!editingId} + autoComplete={editingId ? 'new-password' : 'new-password'} + /> + + Role + + + + + + + + + + + setDeleteConfirmId(null)}> + Delete user? + + + This cannot be undone. You cannot delete your own account. + + + + + + + + + ) +} diff --git a/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java b/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java index cd8416a..991a896 100644 --- a/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java +++ b/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java @@ -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()); + } + } diff --git a/src/test/java/de/neitzel/roleplay/common/StateUpdateResponseDeserializationTest.java b/src/test/java/de/neitzel/roleplay/common/StateUpdateResponseDeserializationTest.java index 48e1811..ff54bc6 100644 --- a/src/test/java/de/neitzel/roleplay/common/StateUpdateResponseDeserializationTest.java +++ b/src/test/java/de/neitzel/roleplay/common/StateUpdateResponseDeserializationTest.java @@ -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()); + } } diff --git a/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java b/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java index 8347549..c72892c 100644 --- a/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java +++ b/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java @@ -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); + } } diff --git a/src/test/java/de/neitzel/roleplay/fascade/UsersIntegrationTest.java b/src/test/java/de/neitzel/roleplay/fascade/UsersIntegrationTest.java new file mode 100644 index 0000000..9afeb4d --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/fascade/UsersIntegrationTest.java @@ -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); + } +}