diff --git a/README.md b/README.md
index 28b46b6..01dc0c7 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,15 @@ Minimal Quarkus (Java 21) project scaffold for the RolePlay service.
- `src/main/java/de/neitzel/roleplay/fascade`: external facades
- `src/main/resources/db/migration`: Liquibase changelog location
+## Login
+
+The app requires authentication. A default admin user is seeded on first run:
+
+- **Username:** `admin`
+- **Password:** `changeme`
+
+Change the password after first login (e.g. by creating a new admin user and retiring the default, or via a future password-change feature). Only users with the `admin` role can create new users (POST `/api/v1/admin/users`).
+
## Build and test
```zsh
diff --git a/pom.xml b/pom.xml
index 404916d..03316eb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -74,6 +74,10 @@
io.quarkus
quarkus-hibernate-orm-panache
+
+ io.quarkus
+ quarkus-security-jpa
+
io.quarkus
quarkus-rest-client-config
@@ -101,6 +105,16 @@
${mockito.version}
test
+
+ io.quarkus
+ quarkus-junit5
+ test
+
+
+ io.rest-assured
+ rest-assured
+ test
+
diff --git a/src/main/java/de/neitzel/roleplay/LoginDebugStartup.java b/src/main/java/de/neitzel/roleplay/LoginDebugStartup.java
new file mode 100644
index 0000000..db24aea
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/LoginDebugStartup.java
@@ -0,0 +1,36 @@
+package de.neitzel.roleplay;
+
+import de.neitzel.roleplay.data.UserEntity;
+import io.quarkus.elytron.security.common.BcryptUtil;
+import io.quarkus.runtime.StartupEvent;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import org.jboss.logging.Logger;
+
+/**
+ * Logs login-related debug info at startup: whether the seeded admin user exists
+ * and whether the stored password hash matches "changeme". Helps diagnose login failures.
+ */
+@ApplicationScoped
+public class LoginDebugStartup {
+
+ private static final Logger LOG = Logger.getLogger(LoginDebugStartup.class);
+
+ private static final String DEFAULT_ADMIN_PASSWORD = "changeme";
+
+ void onStart(@Observes StartupEvent event) {
+ UserEntity admin = (UserEntity) UserEntity.find("username", "admin").firstResult();
+ if (admin == null) {
+ LOG.warn("Login debug: No user 'admin' found in database. Create the admin user (e.g. via Liquibase seed).");
+ return;
+ }
+ String hash = admin.getPassword();
+ boolean passwordMatches = hash != null && BcryptUtil.matches(DEFAULT_ADMIN_PASSWORD, hash);
+ if (passwordMatches) {
+ LOG.info("Login debug: User 'admin' exists; password matches '" + DEFAULT_ADMIN_PASSWORD + "'.");
+ } else {
+ LOG.warn("Login debug: User 'admin' exists but stored password hash does NOT match '" + DEFAULT_ADMIN_PASSWORD + "'. "
+ + "Reset the password or update the seed hash in v002__users_and_roles.xml.");
+ }
+ }
+}
diff --git a/src/main/java/de/neitzel/roleplay/business/UserService.java b/src/main/java/de/neitzel/roleplay/business/UserService.java
new file mode 100644
index 0000000..8c21778
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/business/UserService.java
@@ -0,0 +1,71 @@
+package de.neitzel.roleplay.business;
+
+import de.neitzel.roleplay.common.CreateUserRequest;
+import de.neitzel.roleplay.common.UserSummary;
+import de.neitzel.roleplay.data.UserEntity;
+import de.neitzel.roleplay.data.UserRepository;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Business service for application users. Used by admin-only user management endpoints.
+ */
+@ApplicationScoped
+public class UserService {
+
+ private final UserRepository userRepository;
+
+ @Inject
+ public UserService(final UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ /**
+ * Returns all users as summaries (no password).
+ *
+ * @return list of user summaries
+ */
+ public List listUsers() {
+ return userRepository.listAll().stream()
+ .map(UserService::toSummary)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Creates a new user with the given username, plain password (hashed with bcrypt), and role.
+ *
+ * @param request create request (username, password, role)
+ * @return the created user summary
+ * @throws IllegalArgumentException if username is blank, password is blank, role is invalid, or username already exists
+ */
+ public UserSummary createUser(final CreateUserRequest request) {
+ String username = request.getUsername();
+ String password = request.getPassword();
+ String role = request.getRole();
+ if (username == null || username.isBlank()) {
+ throw new IllegalArgumentException("Username is required");
+ }
+ if (password == null || password.isBlank()) {
+ throw new IllegalArgumentException("Password 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'");
+ }
+ if (userRepository.findByUsername(username) != null) {
+ throw new IllegalArgumentException("Username already exists: " + username);
+ }
+ UserEntity entity = UserEntity.add(username, password, role);
+ return toSummary(entity);
+ }
+
+ 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/CreateUserRequest.java b/src/main/java/de/neitzel/roleplay/common/CreateUserRequest.java
new file mode 100644
index 0000000..36468e4
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/common/CreateUserRequest.java
@@ -0,0 +1,54 @@
+package de.neitzel.roleplay.common;
+
+/**
+ * Request body for creating a new user (admin-only).
+ */
+public final class CreateUserRequest {
+
+ private String username;
+ private String password;
+ private String role;
+
+ /**
+ * Default constructor for JSON.
+ */
+ public CreateUserRequest() {
+ }
+
+ /**
+ * Creates a request with the given fields.
+ *
+ * @param username login name
+ * @param password plain-text password (will be hashed)
+ * @param role role (e.g. admin, user)
+ */
+ public CreateUserRequest(final String username, final String password, final String role) {
+ this.username = username;
+ this.password = password;
+ this.role = role;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(final String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(final String password) {
+ this.password = password;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(final String role) {
+ this.role = role;
+ }
+}
diff --git a/src/main/java/de/neitzel/roleplay/common/UserSummary.java b/src/main/java/de/neitzel/roleplay/common/UserSummary.java
new file mode 100644
index 0000000..0f1edee
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/common/UserSummary.java
@@ -0,0 +1,56 @@
+package de.neitzel.roleplay.common;
+
+import java.util.UUID;
+
+/**
+ * DTO for listing users (id, username, role). Does not expose password.
+ */
+public final class UserSummary {
+
+ private UUID id;
+ private String username;
+ private String role;
+
+ /**
+ * Default constructor for JSON.
+ */
+ public UserSummary() {
+ }
+
+ /**
+ * Creates a summary for a user.
+ *
+ * @param id user id
+ * @param username login name
+ * @param role role (e.g. admin, user)
+ */
+ public UserSummary(final UUID id, final String username, final String role) {
+ this.id = id;
+ this.username = username;
+ this.role = role;
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(final UUID id) {
+ this.id = id;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(final String username) {
+ this.username = username;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(final String role) {
+ this.role = role;
+ }
+}
diff --git a/src/main/java/de/neitzel/roleplay/data/UserEntity.java b/src/main/java/de/neitzel/roleplay/data/UserEntity.java
new file mode 100644
index 0000000..4dc4e8d
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/data/UserEntity.java
@@ -0,0 +1,121 @@
+package de.neitzel.roleplay.data;
+
+import io.quarkus.elytron.security.common.BcryptUtil;
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import io.quarkus.security.jpa.Password;
+import io.quarkus.security.jpa.Roles;
+import io.quarkus.security.jpa.UserDefinition;
+import io.quarkus.security.jpa.Username;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import java.util.UUID;
+
+/**
+ * JPA entity for an application user stored in {@code rp_user}.
+ * Used by Quarkus Security JPA for form-based authentication (username/password, roles).
+ */
+@Entity
+@Table(name = "rp_user")
+@UserDefinition
+public class UserEntity extends PanacheEntityBase {
+
+ @Id
+ @Column(name = "id", length = 36, nullable = false, updatable = false)
+ private UUID id;
+
+ @Username
+ @Column(name = "username", nullable = false, unique = true, length = 255)
+ private String username;
+
+ @Password
+ @Column(name = "password", nullable = false, length = 255)
+ private String password;
+
+ @Roles
+ @Column(name = "role", nullable = false, length = 50)
+ private String role;
+
+ /**
+ * Default constructor for JPA.
+ */
+ public UserEntity() {
+ }
+
+ /**
+ * Returns the unique identifier of this user.
+ */
+ public UUID getId() {
+ return id;
+ }
+
+ /**
+ * Sets the unique identifier of this user.
+ */
+ public void setId(final UUID id) {
+ this.id = id;
+ }
+
+ /**
+ * Returns the login name of this user.
+ */
+ public String getUsername() {
+ return username;
+ }
+
+ /**
+ * Sets the login name of this user.
+ */
+ public void setUsername(final String username) {
+ this.username = username;
+ }
+
+ /**
+ * Returns the bcrypt-hashed password of this user.
+ */
+ public String getPassword() {
+ return password;
+ }
+
+ /**
+ * Sets the password (should be bcrypt-hashed, e.g. via {@link BcryptUtil#bcryptHash(String)}).
+ */
+ public void setPassword(final String password) {
+ this.password = password;
+ }
+
+ /**
+ * Returns the single role of this user (e.g. {@code admin} or {@code user}).
+ */
+ public String getRole() {
+ return role;
+ }
+
+ /**
+ * Sets the role of this user.
+ */
+ public void setRole(final String role) {
+ this.role = role;
+ }
+
+ /**
+ * Creates a new user with the given username, plain password (hashed with bcrypt), and role.
+ *
+ * @param username login name
+ * @param plainPassword plain-text password (will be hashed)
+ * @param role role name (e.g. admin, user)
+ * @return the persisted entity
+ */
+ public static UserEntity add(final String username, final String plainPassword, final String role) {
+ final UserEntity user = new UserEntity();
+ user.setId(UUID.randomUUID());
+ user.setUsername(username);
+ user.setPassword(BcryptUtil.bcryptHash(plainPassword));
+ user.setRole(role);
+ user.persist();
+ return user;
+ }
+}
diff --git a/src/main/java/de/neitzel/roleplay/data/UserRepository.java b/src/main/java/de/neitzel/roleplay/data/UserRepository.java
new file mode 100644
index 0000000..9230ff2
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/data/UserRepository.java
@@ -0,0 +1,32 @@
+package de.neitzel.roleplay.data;
+
+import io.quarkus.hibernate.orm.panache.PanacheRepository;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import java.util.List;
+
+/**
+ * Panache repository for {@link UserEntity}. Used for admin user listing and lookup by username.
+ */
+@ApplicationScoped
+public class UserRepository implements PanacheRepository {
+
+ /**
+ * Returns all users ordered by username.
+ */
+ @Override
+ public List listAll() {
+ return list("ORDER BY username");
+ }
+
+ /**
+ * Finds a user by username.
+ *
+ * @param username the login name
+ * @return the entity or null if not found
+ */
+ public UserEntity findByUsername(final String username) {
+ return find("username", username).firstResult();
+ }
+}
diff --git a/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java b/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java
new file mode 100644
index 0000000..08a6292
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java
@@ -0,0 +1,73 @@
+package de.neitzel.roleplay.fascade;
+
+import io.quarkus.security.identity.SecurityIdentity;
+import jakarta.inject.Inject;
+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.core.MediaType;
+import jakarta.ws.rs.core.NewCookie;
+import jakarta.ws.rs.core.Response;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+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.
+ */
+@Path("auth")
+@Produces(MediaType.APPLICATION_JSON)
+public class AuthResource {
+
+ private static final String DEFAULT_COOKIE_NAME = "quarkus-credential";
+
+ private final SecurityIdentity securityIdentity;
+ private final String sessionCookieName;
+
+ @Inject
+ public AuthResource(
+ final SecurityIdentity securityIdentity,
+ @ConfigProperty(name = "quarkus.http.auth.form.cookie-name", defaultValue = DEFAULT_COOKIE_NAME)
+ final String sessionCookieName) {
+ this.securityIdentity = securityIdentity;
+ this.sessionCookieName = sessionCookieName;
+ }
+
+ /**
+ * Returns the current user's username and roles. Used by the frontend to check login state.
+ * Returns 401 when not authenticated (handled by Quarkus permission policy).
+ */
+ @GET
+ @Path("me")
+ public Map me() {
+ if (securityIdentity.isAnonymous()) {
+ throw new ForbiddenException("Not authenticated");
+ }
+ return Map.of(
+ "username", securityIdentity.getPrincipal().getName(),
+ "roles", securityIdentity.getRoles().stream().collect(Collectors.toList()));
+ }
+
+ /**
+ * Logs out the current user by clearing the session cookie.
+ * Returns 204 No Content. Requires authenticated user.
+ */
+ @POST
+ @Path("logout")
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response logout() {
+ if (securityIdentity.isAnonymous()) {
+ throw new ForbiddenException("Not authenticated");
+ }
+ NewCookie clearCookie = new NewCookie.Builder(sessionCookieName)
+ .value("")
+ .path("/")
+ .maxAge(0)
+ .build();
+ return Response.noContent().cookie(clearCookie).build();
+ }
+}
diff --git a/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java b/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java
new file mode 100644
index 0000000..e79656d
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java
@@ -0,0 +1,58 @@
+package de.neitzel.roleplay.fascade;
+
+import de.neitzel.roleplay.business.UserService;
+import de.neitzel.roleplay.common.CreateUserRequest;
+import de.neitzel.roleplay.common.UserSummary;
+
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import java.util.List;
+
+/**
+ * REST resource for admin-only user management: list users and create user.
+ * Path is relative to {@code quarkus.rest.path} (/api/v1), so full path is /api/v1/admin/users.
+ */
+@Path("admin/users")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+@RolesAllowed("admin")
+public class UsersResource {
+
+ private final UserService userService;
+
+ @Inject
+ public UsersResource(final UserService userService) {
+ this.userService = userService;
+ }
+
+ /**
+ * Returns all users (id, username, role). Admin only.
+ */
+ @GET
+ public List listUsers() {
+ return userService.listUsers();
+ }
+
+ /**
+ * Creates a new user with the given username, password, and role. Admin only.
+ * Returns 201 with the created user summary.
+ */
+ @POST
+ public Response createUser(final CreateUserRequest request) {
+ try {
+ UserSummary created = userService.createUser(request);
+ return Response.status(Response.Status.CREATED).entity(created).build();
+ } catch (final IllegalArgumentException e) {
+ throw new BadRequestException(e.getMessage());
+ }
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 231334e..685f6b1 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -3,6 +3,23 @@ quarkus:
name: roleplay
http:
root-path: /
+ # Form-based auth: SPA mode (no redirects; 200/401). Session in encrypted cookie.
+ auth:
+ form:
+ enabled: true
+ login-page: ""
+ landing-page: ""
+ error-page: ""
+ http-only-cookie: false
+ session:
+ encryption-key: roleplay-session-secret-key-min-16chars
+ permission:
+ form-login:
+ paths: /j_security_check
+ policy: permit
+ api:
+ paths: /api/v1/*
+ policy: authenticated
datasource:
db-kind: h2
jdbc:
@@ -28,3 +45,10 @@ quarkus:
level: DEBUG
"org.jboss.resteasy.reactive.client.logging":
level: DEBUG
+ # Login debugging: form auth and security identity
+ "io.quarkus.vertx.http.runtime.security":
+ level: DEBUG
+ "io.quarkus.security":
+ level: DEBUG
+ "de.neitzel.roleplay":
+ level: INFO
diff --git a/src/main/resources/db/migration/changelog.xml b/src/main/resources/db/migration/changelog.xml
index b775c7e..e776ff9 100644
--- a/src/main/resources/db/migration/changelog.xml
+++ b/src/main/resources/db/migration/changelog.xml
@@ -6,5 +6,6 @@
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
+
diff --git a/src/main/resources/db/migration/v002__users_and_roles.xml b/src/main/resources/db/migration/v002__users_and_roles.xml
new file mode 100644
index 0000000..4804bc5
--- /dev/null
+++ b/src/main/resources/db/migration/v002__users_and_roles.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stores application users for form-based auth. Default login: admin / changeme.
+
+
+
+
+
+
+
+
+
+
+
+ id = '11111111-1111-1111-1111-111111111111'
+
+
+
+
diff --git a/src/main/resources/openapi-roleplay-public-v1.yml b/src/main/resources/openapi-roleplay-public-v1.yml
index e6b639b..1cf8b35 100644
--- a/src/main/resources/openapi-roleplay-public-v1.yml
+++ b/src/main/resources/openapi-roleplay-public-v1.yml
@@ -23,6 +23,10 @@ tags:
description: List and retrieve saved scenario templates
- name: characters
description: List and retrieve saved character templates
+ - name: auth
+ description: Authentication (current user, logout)
+ - name: users
+ description: User management (admin only)
paths:
@@ -411,6 +415,84 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
+ /auth/me:
+ get:
+ operationId: getAuthMe
+ summary: Current user info
+ description: Returns the authenticated user's username and roles. Requires session cookie.
+ tags:
+ - auth
+ responses:
+ "200":
+ description: Current user.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthMeResponse'
+ "401":
+ description: Not authenticated.
+
+ /auth/logout:
+ post:
+ operationId: logout
+ summary: Log out
+ description: Clears the session cookie. Requires authenticated user.
+ tags:
+ - auth
+ responses:
+ "204":
+ description: Logged out.
+ "401":
+ description: Not authenticated.
+
+ /admin/users:
+ get:
+ operationId: listUsers
+ summary: List users (admin only)
+ description: Returns all users (id, username, role). Admin role required.
+ tags:
+ - users
+ responses:
+ "200":
+ description: List of users.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserListResponse'
+ "401":
+ description: Not authenticated.
+ "403":
+ description: Forbidden (admin only).
+ post:
+ operationId: createUser
+ summary: Create user (admin only)
+ description: Creates a new user with the given username, password, and role.
+ tags:
+ - users
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateUserRequest'
+ responses:
+ "201":
+ description: User created.
+ 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).
+
components:
parameters:
@@ -1017,6 +1099,70 @@ components:
- type
- title
+ # ─── Auth and users ───────────────────────────────────────────────────────
+
+ AuthMeResponse:
+ type: object
+ description: Current authenticated user.
+ properties:
+ username:
+ type: string
+ description: Login name.
+ roles:
+ type: array
+ items:
+ type: string
+ description: Assigned roles (e.g. admin, user).
+ required:
+ - username
+ - roles
+
+ UserSummary:
+ type: object
+ description: User without password (for list and create response).
+ properties:
+ id:
+ type: string
+ format: uuid
+ description: User UUID.
+ username:
+ type: string
+ description: Login name.
+ role:
+ type: string
+ description: Role (admin or user).
+ required:
+ - id
+ - username
+ - role
+
+ UserListResponse:
+ type: array
+ items:
+ $ref: '#/components/schemas/UserSummary'
+ description: List of users (admin only).
+
+ CreateUserRequest:
+ type: object
+ description: Request to create a new user (admin only).
+ properties:
+ username:
+ type: string
+ description: Login name.
+ password:
+ type: string
+ description: Plain-text password (hashed on server).
+ role:
+ type: string
+ enum:
+ - admin
+ - user
+ description: Role to assign.
+ required:
+ - username
+ - password
+ - role
+
# ─── Error ────────────────────────────────────────────────────────────────
ErrorResponse:
diff --git a/src/main/web/src/App.tsx b/src/main/web/src/App.tsx
index 4e1b2ef..4324924 100644
--- a/src/main/web/src/App.tsx
+++ b/src/main/web/src/App.tsx
@@ -1,25 +1,37 @@
-import {BrowserRouter, Route, Routes} from 'react-router-dom'
+import { BrowserRouter, Route, Routes } from 'react-router-dom'
import AppLayout from './components/AppLayout'
+import ProtectedRoute from './components/ProtectedRoute'
+import { AuthProvider } from './contexts/AuthContext'
import StartPage from './pages/StartPage'
import SessionPage from './pages/SessionPage'
import ScenariosPage from './pages/ScenariosPage'
import CharactersPage from './pages/CharactersPage'
+import LoginPage from './pages/LoginPage'
/**
* Root application component. Sets up client-side routing with app shell:
- * Home (start flow), Scenarios, Characters, and Session.
+ * Login (public), then Home, Scenarios, Characters, Session (protected).
*/
export default function App() {
return (
-
- }>
- }/>
- }/>
- }/>
- }/>
-
-
+
+
+ } />
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
+
)
}
diff --git a/src/main/web/src/api/index.ts b/src/main/web/src/api/index.ts
index 4b7ccf4..cfc5730 100644
--- a/src/main/web/src/api/index.ts
+++ b/src/main/web/src/api/index.ts
@@ -7,6 +7,13 @@
* the project in an IDE to make these imports resolve.
*/
export * from './generated/index'
+import { Configuration } from './generated/runtime'
/** Base URL used by the generated fetch client. Always points to /api/v1. */
export const API_BASE = '/api/v1'
+
+/** Default configuration for API clients; sends session cookie (credentials: 'include'). */
+export const apiConfiguration = new Configuration({
+ basePath: API_BASE,
+ credentials: 'include',
+})
diff --git a/src/main/web/src/components/AppLayout.tsx b/src/main/web/src/components/AppLayout.tsx
index 5399bd3..91bcfcd 100644
--- a/src/main/web/src/components/AppLayout.tsx
+++ b/src/main/web/src/components/AppLayout.tsx
@@ -1,12 +1,15 @@
-import {AppBar, Box, Toolbar, Typography} from '@mui/material'
-import {Link as RouterLink, Outlet, useLocation} from 'react-router-dom'
-import {Link} from '@mui/material'
+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 { useAuth } from '../contexts/AuthContext'
/**
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
+ * Shows current username and Logout when authenticated.
*/
export default function AppLayout() {
const location = useLocation()
+ const { user, logout } = useAuth()
const navItems = [
{ to: '/', label: 'Home' },
{ to: '/scenarios', label: 'Scenarios' },
@@ -30,7 +33,7 @@ export default function AppLayout() {
>
RolePlay
-
+
{navItems.map(({ to, label }) => (
))}
+ {user && (
+ <>
+
+ {user.username}
+
+
+ >
+ )}
diff --git a/src/main/web/src/components/ProtectedRoute.tsx b/src/main/web/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..38425a9
--- /dev/null
+++ b/src/main/web/src/components/ProtectedRoute.tsx
@@ -0,0 +1,41 @@
+import { useEffect, type ReactNode } from 'react'
+import { Navigate, useLocation } from 'react-router-dom'
+import { useAuth } from '../contexts/AuthContext'
+import { Box, CircularProgress, Typography } from '@mui/material'
+
+interface ProtectedRouteProps {
+ children: ReactNode
+}
+
+/**
+ * Wraps content that requires authentication. If not logged in, redirects to /login.
+ * Shows a loading spinner while auth state is being determined.
+ */
+export default function ProtectedRoute({ children }: ProtectedRouteProps) {
+ const { user, loading } = useAuth()
+ const location = useLocation()
+
+ if (loading) {
+ return (
+
+
+ Checking authentication…
+
+ )
+ }
+
+ if (!user) {
+ return
+ }
+
+ return <>{children}>
+}
diff --git a/src/main/web/src/contexts/AuthContext.tsx b/src/main/web/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000..be8c9cc
--- /dev/null
+++ b/src/main/web/src/contexts/AuthContext.tsx
@@ -0,0 +1,106 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react'
+import { useNavigate } from 'react-router-dom'
+
+const API_BASE = '/api/v1'
+
+export interface AuthUser {
+ username: string
+ roles: string[]
+}
+
+interface AuthContextValue {
+ user: AuthUser | null
+ loading: boolean
+ error: boolean
+ logout: () => Promise
+ refresh: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+export function useAuth(): AuthContextValue {
+ const value = useContext(AuthContext)
+ if (!value) {
+ throw new Error('useAuth must be used within AuthProvider')
+ }
+ return value
+}
+
+interface AuthProviderProps {
+ children: ReactNode
+}
+
+/**
+ * Provides auth state (current user, loading, error) and logout.
+ * Fetches GET /api/v1/auth/me with credentials so the session cookie is sent.
+ */
+export function AuthProvider({ children }: AuthProviderProps) {
+ const [user, setUser] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const navigate = useNavigate()
+
+ const fetchMe = useCallback(async () => {
+ setLoading(true)
+ setError(false)
+ try {
+ const res = await fetch(`${API_BASE}/auth/me`, { credentials: 'include' })
+ if (res.ok) {
+ const data = await res.json()
+ setUser({
+ username: data.username ?? '',
+ roles: Array.isArray(data.roles) ? data.roles : [],
+ })
+ } else {
+ setUser(null)
+ if (res.status === 401) {
+ setError(false)
+ } else {
+ setError(true)
+ }
+ }
+ } catch {
+ setUser(null)
+ setError(true)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ fetchMe()
+ }, [fetchMe])
+
+ const logout = useCallback(async () => {
+ try {
+ await fetch(`${API_BASE}/auth/logout`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+ } finally {
+ setUser(null)
+ navigate('/login', { replace: true })
+ }
+ }, [navigate])
+
+ const value: AuthContextValue = {
+ user,
+ loading,
+ error,
+ logout,
+ refresh: fetchMe,
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/main/web/src/pages/CharactersPage.tsx b/src/main/web/src/pages/CharactersPage.tsx
index 0268c95..82c5dcc 100644
--- a/src/main/web/src/pages/CharactersPage.tsx
+++ b/src/main/web/src/pages/CharactersPage.tsx
@@ -19,10 +19,10 @@ import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import type {CharacterDefinition, CreateCharacterRequest} from '../api/generated/index'
-import {CharactersApi, Configuration} from '../api/generated/index'
+import { CharactersApi } from '../api/generated/index'
+import { apiConfiguration } from '../api'
-const API_BASE = '/api/v1'
-const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE}))
+const charactersApi = new CharactersApi(apiConfiguration)
/** Parse comma- or newline-separated string into trimmed non-empty strings. */
function parseList(value: string | undefined): string[] {
diff --git a/src/main/web/src/pages/LoginPage.tsx b/src/main/web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..812100d
--- /dev/null
+++ b/src/main/web/src/pages/LoginPage.tsx
@@ -0,0 +1,106 @@
+import { useState } from 'react'
+import { useNavigate, useLocation } from 'react-router-dom'
+import {
+ Alert,
+ Box,
+ Button,
+ Container,
+ Paper,
+ TextField,
+ Typography,
+} from '@mui/material'
+import { useAuth } from '../contexts/AuthContext'
+
+/**
+ * Login page. POSTs credentials to /j_security_check (form-based auth).
+ * On success refreshes auth state and redirects to home or the previously attempted URL.
+ */
+export default function LoginPage() {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const { refresh } = useAuth()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+ setSubmitting(true)
+ try {
+ const formData = new URLSearchParams()
+ formData.append('j_username', username)
+ formData.append('j_password', password)
+ const res = await fetch('/j_security_check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: formData.toString(),
+ credentials: 'include',
+ })
+ if (res.ok) {
+ await refresh()
+ navigate(from, { replace: true })
+ } else {
+ setError('Invalid credentials')
+ }
+ } catch {
+ setError('Login failed')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Sign in
+
+
+ Default: admin / changeme
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/main/web/src/pages/ScenariosPage.tsx b/src/main/web/src/pages/ScenariosPage.tsx
index 681fad6..559aecd 100644
--- a/src/main/web/src/pages/ScenariosPage.tsx
+++ b/src/main/web/src/pages/ScenariosPage.tsx
@@ -30,11 +30,11 @@ import type {
ScenarioSetup,
ScenarioSummary,
} from '../api/generated/index'
-import {CharactersApi, Configuration, ScenariosApi} from '../api/generated/index'
+import { CharactersApi, ScenariosApi } from '../api/generated/index'
+import { apiConfiguration } from '../api'
-const API_BASE = '/api/v1'
-const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE}))
-const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE}))
+const scenariosApi = new ScenariosApi(apiConfiguration)
+const charactersApi = new CharactersApi(apiConfiguration)
/**
* Scenarios management page. List cards with New/Edit/Delete/Start; form with character assignment.
diff --git a/src/main/web/src/pages/SessionPage.tsx b/src/main/web/src/pages/SessionPage.tsx
index cfb311f..777cd7e 100644
--- a/src/main/web/src/pages/SessionPage.tsx
+++ b/src/main/web/src/pages/SessionPage.tsx
@@ -38,14 +38,14 @@ import type {
TurnRequest,
UpdateSessionRequest,
} from '../api/generated/index'
-import {Configuration, SessionsApi, TurnsApi, UserActionRequestTypeEnum,} from '../api/generated/index'
+import { SessionsApi, TurnsApi, UserActionRequestTypeEnum } from '../api/generated/index'
import NarrativeView from '../components/NarrativeView'
import SuggestionList from '../components/SuggestionList'
import ActionInput from '../components/ActionInput'
+import { apiConfiguration } from '../api'
-const API_BASE = '/api/v1'
-const sessionsApi = new SessionsApi(new Configuration({basePath: API_BASE}))
-const turnsApi = new TurnsApi(new Configuration({basePath: API_BASE}))
+const sessionsApi = new SessionsApi(apiConfiguration)
+const turnsApi = new TurnsApi(apiConfiguration)
/**
* Active role-play session page. Loads the session state, displays the
diff --git a/src/main/web/src/pages/StartPage.tsx b/src/main/web/src/pages/StartPage.tsx
index 239d8ab..00ffd10 100644
--- a/src/main/web/src/pages/StartPage.tsx
+++ b/src/main/web/src/pages/StartPage.tsx
@@ -39,19 +39,16 @@ import type {
ScenarioSummary,
} from '../api/generated/index'
import {
- Configuration,
CreateSessionRequestSafetyLevelEnum,
ModelsApi,
ScenariosApi,
SessionsApi,
} from '../api/generated/index'
+import { apiConfiguration } from '../api'
-/***** API base path – must match quarkus.rest.path in application.yml */
-const API_BASE = '/api/v1'
-
-const modelsApi = new ModelsApi(new Configuration({basePath: API_BASE}))
-const sessionsApi = new SessionsApi(new Configuration({basePath: API_BASE}))
-const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE}))
+const modelsApi = new ModelsApi(apiConfiguration)
+const sessionsApi = new SessionsApi(apiConfiguration)
+const scenariosApi = new ScenariosApi(apiConfiguration)
/**
* Landing page where the user selects an Ollama model and optional settings
diff --git a/src/main/web/vite.config.ts b/src/main/web/vite.config.ts
index 76949db..4cebb3a 100644
--- a/src/main/web/vite.config.ts
+++ b/src/main/web/vite.config.ts
@@ -14,6 +14,11 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
+ // Form-based login; must reach Quarkus in dev
+ '/j_security_check': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ },
},
},
})
diff --git a/src/test/java/de/neitzel/roleplay/business/BcryptHashGenerator.java b/src/test/java/de/neitzel/roleplay/business/BcryptHashGenerator.java
new file mode 100644
index 0000000..f02cad4
--- /dev/null
+++ b/src/test/java/de/neitzel/roleplay/business/BcryptHashGenerator.java
@@ -0,0 +1,36 @@
+package de.neitzel.roleplay.business;
+
+import io.quarkus.elytron.security.common.BcryptUtil;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Generates BCrypt hash for the default admin password for use in Liquibase seed.
+ * Run: mvn test -Dtest=BcryptHashGenerator#generateChangemeHash
+ * Then paste the printed hash into v002__users_and_roles.xml seed changeset.
+ */
+final class BcryptHashGenerator {
+
+ /** BCrypt hash stored in v002__users_and_roles.xml for admin user (password: changeme). */
+ private static final String SEEDED_ADMIN_PASSWORD_HASH =
+ "$2a$10$dFA336yJOw3.pogwU.vXVu3BRRfBr1yjhGC6O6nfC2qGFA5e29.cO";
+
+ @Test
+ void generateChangemeHash() {
+ String hash = BcryptUtil.bcryptHash("changeme");
+ System.out.println("BCrypt hash for 'changeme': " + hash);
+ assert hash != null && hash.startsWith("$2");
+ }
+
+ /**
+ * Verifies the seeded admin password hash in the migration matches "changeme".
+ * If this fails, run generateChangemeHash() and update v002__users_and_roles.xml with the new hash.
+ */
+ @Test
+ void seededAdminHashMatchesChangeme() {
+ boolean matches = BcryptUtil.matches("changeme", SEEDED_ADMIN_PASSWORD_HASH);
+ assertTrue(matches, "Seeded admin password hash in v002__users_and_roles.xml must match 'changeme'. "
+ + "Run generateChangemeHash() and update the migration with the printed hash.");
+ }
+}
diff --git a/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java b/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java
new file mode 100644
index 0000000..cd8416a
--- /dev/null
+++ b/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java
@@ -0,0 +1,87 @@
+package de.neitzel.roleplay.business;
+
+import de.neitzel.roleplay.common.CreateUserRequest;
+import de.neitzel.roleplay.common.UserSummary;
+import de.neitzel.roleplay.data.UserEntity;
+import de.neitzel.roleplay.data.UserRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+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.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link UserService}.
+ */
+@ExtendWith(MockitoExtension.class)
+class UserServiceTest {
+
+ @Mock
+ private UserRepository userRepository;
+
+ private UserService userService;
+
+ @BeforeEach
+ void setUp() {
+ userService = new UserService(userRepository);
+ }
+
+ @Test
+ void listUsersReturnsMappedSummaries() {
+ UserEntity entity = new UserEntity();
+ entity.setId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
+ entity.setUsername("alice");
+ entity.setRole("user");
+ when(userRepository.listAll()).thenReturn(List.of(entity));
+
+ List result = userService.listUsers();
+
+ assertEquals(1, result.size());
+ UserSummary summary = result.get(0);
+ assertEquals(UUID.fromString("11111111-1111-1111-1111-111111111111"), summary.getId());
+ assertEquals("alice", summary.getUsername());
+ assertEquals("user", summary.getRole());
+ }
+
+ @Test
+ void createUserRejectsBlankUsername() {
+ CreateUserRequest request = new CreateUserRequest("", "secret", "user");
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
+ assertEquals("Username is required", e.getMessage());
+ }
+
+ @Test
+ void createUserRejectsBlankPassword() {
+ CreateUserRequest request = new CreateUserRequest("alice", "", "user");
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
+ assertEquals("Password is required", e.getMessage());
+ }
+
+ @Test
+ void createUserRejectsInvalidRole() {
+ CreateUserRequest request = new CreateUserRequest("alice", "secret", "superuser");
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
+ assertEquals("Role must be 'admin' or 'user'", e.getMessage());
+ }
+
+ @Test
+ void createUserRejectsDuplicateUsername() {
+ CreateUserRequest request = new CreateUserRequest("alice", "secret", "user");
+ when(userRepository.findByUsername("alice")).thenReturn(new UserEntity());
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
+ assertEquals("Username already exists: alice", e.getMessage());
+ }
+
+}
diff --git a/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java b/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java
new file mode 100644
index 0000000..8347549
--- /dev/null
+++ b/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java
@@ -0,0 +1,76 @@
+package de.neitzel.roleplay.fascade;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.http.Cookies;
+import org.junit.jupiter.api.Test;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.hasItem;
+
+/**
+ * Integration tests for form-based auth: login, auth/me, and protected API access.
+ */
+@QuarkusTest
+class AuthIntegrationTest {
+
+ @Test
+ void unauthenticatedRequestToApiReturns401() {
+ given()
+ .when()
+ .get("/api/v1/scenarios")
+ .then()
+ .statusCode(401);
+ }
+
+ @Test
+ void unauthenticatedRequestToAuthMeReturns401() {
+ given()
+ .when()
+ .get("/api/v1/auth/me")
+ .then()
+ .statusCode(401);
+ }
+
+ @Test
+ void loginThenAuthMeAndScenariosSucceed() {
+ 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)
+ .when()
+ .get("/api/v1/auth/me")
+ .then()
+ .statusCode(200)
+ .body("username", is("admin"))
+ .body("roles", hasItem("admin"));
+
+ given()
+ .cookies(cookies)
+ .when()
+ .get("/api/v1/scenarios")
+ .then()
+ .statusCode(200);
+ }
+
+ @Test
+ void invalidLoginReturns401() {
+ given()
+ .contentType("application/x-www-form-urlencoded")
+ .formParam("j_username", "admin")
+ .formParam("j_password", "wrong")
+ .when()
+ .post("/j_security_check")
+ .then()
+ .statusCode(401);
+ }
+}