feat(server): Inventory Sharing – User:Inventory N:1

- Inventories-Tabelle neu: id, created_at
- Users.inventory_id FK → Inventories
- Items/Categories/Locations/Settings: user_id → inventory_id
- DatabaseFactory: Schema-Migration + Data-Migration (user_id→inventory_id)
- InventoryRepository: getEffectiveInventoryId(), createInventory(),
  assignUserToInventory(), cleanupOrphanedInventory(),
  listInventoriesWithUsers(); alle Ops auf inventoryId umgestellt
- UserRepository.create(): legt automatisch neues Inventory an
- InventoryRoutes: löst inventoryId via getEffectiveInventoryId()
- AdminRoutes: PUT /users/{id}/inventory (zuweisen),
  POST /users/{id}/inventory/new (trennen), GET /admin/inventories
- Admin-UI: Inventar-Spalte, 'Inventar wechseln'-Modal, 'Neues
  Inventar'-Button, Inventar-Übersicht (gruppiert)
- InventorySharingTest: 8 neue Integrationstests (Sharing, Isolation,
  Cleanup, Berechtigungen)
- Alle 48 Server-Tests gruen (inkl. bestehende Tests unveraendert)
This commit is contained in:
Jens Reinemann 2026-05-17 00:30:06 +02:00
parent 8576fffdb7
commit c03475e7e5
10 changed files with 919 additions and 31 deletions

View file

@ -3,6 +3,7 @@ package de.krisenvorrat.server.db
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.update
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.mindrot.jbcrypt.BCrypt
@ -20,12 +21,47 @@ internal object DatabaseFactory {
) {
Database.connect(jdbcUrl, driver)
transaction {
SchemaUtils.create(Users, Categories, Locations, Items, Settings, Messages)
SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings, Messages)
SchemaUtils.create(Inventories, Users, Categories, Locations, Items, Settings, Messages)
SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages)
}
migrateUserInventories()
seedAdmin(adminPassword)
}
/**
* Migration: For each existing user without an inventoryId, create an inventory
* and migrate their data from the old user_id column to the new inventory_id column.
*/
private fun migrateUserInventories() {
transaction {
val usersWithoutInventory = Users.selectAll()
.where { Users.inventoryId.isNull() }
.map { it[Users.id] }
for (userId in usersWithoutInventory) {
val inventoryId = UUID.randomUUID().toString()
Inventories.insert {
it[id] = inventoryId
it[createdAt] = System.currentTimeMillis()
}
Users.update({ Users.id eq userId }) {
it[Users.inventoryId] = inventoryId
}
// Migrate existing data from old user_id column to new inventory_id column.
// The exec() calls are wrapped in try/catch because the user_id column may not
// exist in fresh databases (only in upgraded databases from older schema).
try {
exec("UPDATE ITEMS SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
exec("UPDATE CATEGORIES SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
exec("UPDATE LOCATIONS SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
exec("UPDATE SETTINGS SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
} catch (_: Exception) {
// user_id column doesn't exist fresh database, nothing to migrate
}
}
}
}
private fun seedAdmin(adminPassword: String?) {
transaction {
val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0
@ -40,12 +76,19 @@ internal object DatabaseFactory {
logger.warn("==============================================")
generated
}
val adminId = UUID.randomUUID().toString()
val inventoryId = UUID.randomUUID().toString()
Inventories.insert {
it[id] = inventoryId
it[createdAt] = System.currentTimeMillis()
}
Users.insert {
it[id] = UUID.randomUUID().toString()
it[id] = adminId
it[username] = "admin"
it[passwordHash] = BCrypt.hashpw(password, BCrypt.gensalt())
it[createdAt] = System.currentTimeMillis()
it[isAdmin] = true
it[Users.inventoryId] = inventoryId
}
}
}

View file

@ -2,12 +2,19 @@ package de.krisenvorrat.server.db
import org.jetbrains.exposed.sql.Table
internal object Inventories : Table("inventories") {
val id = varchar("id", 36)
val createdAt = long("created_at")
override val primaryKey = PrimaryKey(id)
}
internal object Users : Table("users") {
val id = varchar("id", 36)
val username = varchar("username", 255).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val createdAt = long("created_at")
val isAdmin = bool("is_admin").default(false)
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(id)
}
@ -16,7 +23,7 @@ internal object Categories : Table("categories") {
val pk = integer("pk").autoIncrement()
val id = integer("id")
val name = varchar("name", 255)
val userId = varchar("user_id", 36).nullable()
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(pk)
}
@ -25,7 +32,7 @@ internal object Locations : Table("locations") {
val pk = integer("pk").autoIncrement()
val id = integer("id")
val name = varchar("name", 255)
val userId = varchar("user_id", 36).nullable()
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(pk)
}
@ -42,7 +49,7 @@ internal object Items : Table("items") {
val locationId = integer("location_id")
val notes = text("notes")
val lastUpdated = long("last_updated")
val userId = varchar("user_id", 36).nullable()
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(id)
}
@ -51,7 +58,7 @@ internal object Settings : Table("settings") {
val id = integer("id").autoIncrement()
val key = varchar("key", 255)
val value = text("value")
val userId = varchar("user_id", 36).nullable()
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(id)
}

View file

@ -21,7 +21,8 @@ internal data class UserDto(
val id: String,
val username: String,
val createdAt: Long,
val isAdmin: Boolean
val isAdmin: Boolean,
val inventoryId: String? = null
)
@Serializable
@ -29,3 +30,13 @@ internal data class CreateUserRequest(val username: String, val password: String
@Serializable
internal data class UpdatePasswordRequest(val password: String)
@Serializable
internal data class InventoryWithUsersDto(
val inventoryId: String,
val users: List<UserDto>
)
@Serializable
internal data class AssignInventoryRequest(val inventoryId: String)

View file

@ -43,7 +43,7 @@ internal fun Application.configureRouting(
// Protected endpoints
authenticate("auth-jwt") {
inventoryRoutes(inventoryRepository, wsManager)
adminRoutes(userRepository)
adminRoutes(userRepository, inventoryRepository)
messageRoutes(messageRepository, userRepository, wsManager)
userRoutes(userRepository)
}

View file

@ -1,9 +1,13 @@
package de.krisenvorrat.server.repository
import de.krisenvorrat.server.db.Categories
import de.krisenvorrat.server.db.Inventories
import de.krisenvorrat.server.db.Items
import de.krisenvorrat.server.db.Locations
import de.krisenvorrat.server.db.Settings
import de.krisenvorrat.server.db.Users
import de.krisenvorrat.server.model.InventoryWithUsersDto
import de.krisenvorrat.server.model.UserDto
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
@ -16,21 +20,96 @@ import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import java.util.UUID
internal class InventoryRepository {
fun saveInventory(userId: String, inventory: InventoryDto) {
/**
* Resolves the effective inventoryId for a given userId.
* If the user exists in the DB, returns their assigned inventoryId.
* Falls back to the userId itself for JWT-only users (e.g. test tokens).
*/
fun getEffectiveInventoryId(userId: String): String = transaction {
Users.selectAll()
.where { Users.id eq userId }
.map { it[Users.inventoryId] }
.singleOrNull() ?: userId
}
fun createInventory(): String {
val inventoryId = UUID.randomUUID().toString()
transaction {
Items.deleteWhere { Items.userId eq userId }
Settings.deleteWhere { Settings.userId eq userId }
Categories.deleteWhere { Categories.userId eq userId }
Locations.deleteWhere { Locations.userId eq userId }
Inventories.insert {
it[id] = inventoryId
it[createdAt] = System.currentTimeMillis()
}
}
return inventoryId
}
fun inventoryExists(inventoryId: String): Boolean = transaction {
Inventories.selectAll().where { Inventories.id eq inventoryId }.count() > 0
}
/**
* Assigns a user to a different inventory. Returns the old inventoryId.
*/
fun assignUserToInventory(userId: String, newInventoryId: String): String? = transaction {
val oldInventoryId = Users.selectAll()
.where { Users.id eq userId }
.map { it[Users.inventoryId] }
.singleOrNull()
Users.update({ Users.id eq userId }) {
it[Users.inventoryId] = newInventoryId
}
oldInventoryId
}
/**
* Deletes an inventory if no users are assigned to it and it has no items.
*/
fun cleanupOrphanedInventory(inventoryId: String) = transaction {
val userCount = Users.selectAll()
.where { Users.inventoryId eq inventoryId }
.count()
if (userCount > 0) return@transaction
val hasItems = Items.selectAll()
.where { Items.inventoryId eq inventoryId }
.count() > 0
if (hasItems) return@transaction
Inventories.deleteWhere { Inventories.id eq inventoryId }
}
fun listInventoriesWithUsers(): List<InventoryWithUsersDto> = transaction {
Inventories.selectAll().map { invRow ->
val invId = invRow[Inventories.id]
val users = Users.selectAll()
.where { Users.inventoryId eq invId }
.map {
UserDto(
id = it[Users.id],
username = it[Users.username],
createdAt = it[Users.createdAt],
isAdmin = it[Users.isAdmin],
inventoryId = invId
)
}
InventoryWithUsersDto(inventoryId = invId, users = users)
}
}
fun saveInventory(inventoryId: String, inventory: InventoryDto) {
transaction {
Items.deleteWhere { Items.inventoryId eq inventoryId }
Settings.deleteWhere { Settings.inventoryId eq inventoryId }
Categories.deleteWhere { Categories.inventoryId eq inventoryId }
Locations.deleteWhere { Locations.inventoryId eq inventoryId }
for (category in inventory.categories) {
Categories.insert {
it[id] = category.id
it[name] = category.name
it[Categories.userId] = userId
it[Categories.inventoryId] = inventoryId
}
}
@ -38,7 +117,7 @@ internal class InventoryRepository {
Locations.insert {
it[id] = location.id
it[name] = location.name
it[Locations.userId] = userId
it[Locations.inventoryId] = inventoryId
}
}
@ -55,7 +134,7 @@ internal class InventoryRepository {
it[locationId] = item.locationId
it[notes] = item.notes
it[lastUpdated] = item.lastUpdated
it[Items.userId] = userId
it[Items.inventoryId] = inventoryId
}
}
@ -63,24 +142,24 @@ internal class InventoryRepository {
Settings.insert {
it[key] = setting.key
it[value] = setting.value
it[Settings.userId] = userId
it[Settings.inventoryId] = inventoryId
}
}
}
}
fun loadInventory(userId: String): InventoryDto {
fun loadInventory(inventoryId: String): InventoryDto {
return transaction {
val categories = Categories.selectAll()
.where { Categories.userId eq userId }
.where { Categories.inventoryId eq inventoryId }
.map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) }
val locations = Locations.selectAll()
.where { Locations.userId eq userId }
.where { Locations.inventoryId eq inventoryId }
.map { LocationDto(id = it[Locations.id], name = it[Locations.name]) }
val items = Items.selectAll()
.where { Items.userId eq userId }
.where { Items.inventoryId eq inventoryId }
.map {
ItemDto(
id = it[Items.id],
@ -98,17 +177,17 @@ internal class InventoryRepository {
}
val settings = Settings.selectAll()
.where { Settings.userId eq userId }
.where { Settings.inventoryId eq inventoryId }
.map { SettingDto(key = it[Settings.key], value = it[Settings.value]) }
InventoryDto(categories = categories, locations = locations, items = items, settings = settings)
}
}
fun patchItem(userId: String, itemId: String, item: ItemDto): Boolean {
fun patchItem(inventoryId: String, itemId: String, item: ItemDto): Boolean {
return transaction {
val updated = Items.update({
(Items.id eq itemId) and (Items.userId eq userId)
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
}) {
it[name] = item.name
it[categoryId] = item.categoryId

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.server.repository
import de.krisenvorrat.server.db.Inventories
import de.krisenvorrat.server.db.Users
import de.krisenvorrat.server.model.UserDto
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
@ -8,6 +9,7 @@ import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import java.util.UUID
internal data class UserRow(
val id: String,
@ -21,12 +23,18 @@ internal class UserRepository {
fun create(id: String, username: String, passwordHash: String, isAdmin: Boolean = false): Boolean {
return transaction {
val inventoryId = UUID.randomUUID().toString()
Inventories.insert {
it[Inventories.id] = inventoryId
it[Inventories.createdAt] = System.currentTimeMillis()
}
val inserted = Users.insert {
it[Users.id] = id
it[Users.username] = username
it[Users.passwordHash] = passwordHash
it[Users.createdAt] = System.currentTimeMillis()
it[Users.isAdmin] = isAdmin
it[Users.inventoryId] = inventoryId
}.insertedCount
inserted > 0
}
@ -52,7 +60,8 @@ internal class UserRepository {
id = it[Users.id],
username = it[Users.username],
createdAt = it[Users.createdAt],
isAdmin = it[Users.isAdmin]
isAdmin = it[Users.isAdmin],
inventoryId = it[Users.inventoryId]
)
}
}
@ -77,3 +86,4 @@ internal class UserRepository {
isAdmin = this[Users.isAdmin]
)
}

View file

@ -1,8 +1,10 @@
package de.krisenvorrat.server.routes
import de.krisenvorrat.server.model.AssignInventoryRequest
import de.krisenvorrat.server.model.CreateUserRequest
import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.server.model.UpdatePasswordRequest
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.repository.UserRepository
import de.krisenvorrat.server.security.UserPrincipal
import io.ktor.http.*
@ -13,7 +15,7 @@ import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt
import java.util.UUID
internal fun Route.adminRoutes(userRepository: UserRepository) {
internal fun Route.adminRoutes(userRepository: UserRepository, inventoryRepository: InventoryRepository) {
route("/api/admin/users") {
get {
val principal = call.principal<UserPrincipal>()
@ -60,10 +62,15 @@ internal fun Route.adminRoutes(userRepository: UserRepository) {
call.respond(HttpStatusCode.Conflict, ErrorResponse(status = 409, message = "Cannot delete your own account"))
return@delete
}
val userDto = userRepository.listAll().find { it.id == id }
val oldInventoryId = userDto?.inventoryId
val deleted = userRepository.deleteById(id)
if (!deleted) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "User not found"))
} else {
if (oldInventoryId != null) {
inventoryRepository.cleanupOrphanedInventory(oldInventoryId)
}
call.respond(HttpStatusCode.NoContent)
}
}
@ -91,5 +98,59 @@ internal fun Route.adminRoutes(userRepository: UserRepository) {
call.respond(HttpStatusCode.OK, mapOf("message" to "Password updated"))
}
}
// Assign user to an existing inventory
put("/{id}/inventory") {
val principal = call.principal<UserPrincipal>()
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
if (!principal.isAdmin) {
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
return@put
}
val id = call.parameters["id"] ?: return@put call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing user id")
)
val request = call.receive<AssignInventoryRequest>()
if (!inventoryRepository.inventoryExists(request.inventoryId)) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Inventory not found"))
return@put
}
val oldInventoryId = inventoryRepository.assignUserToInventory(id, request.inventoryId)
if (oldInventoryId != null && oldInventoryId != request.inventoryId) {
inventoryRepository.cleanupOrphanedInventory(oldInventoryId)
}
call.respond(HttpStatusCode.OK, mapOf("inventoryId" to request.inventoryId))
}
// Create a new inventory for a user (disconnect from shared inventory)
post("/{id}/inventory/new") {
val principal = call.principal<UserPrincipal>()
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
if (!principal.isAdmin) {
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
return@post
}
val id = call.parameters["id"] ?: return@post call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing user id")
)
val newInventoryId = inventoryRepository.createInventory()
val oldInventoryId = inventoryRepository.assignUserToInventory(id, newInventoryId)
if (oldInventoryId != null && oldInventoryId != newInventoryId) {
inventoryRepository.cleanupOrphanedInventory(oldInventoryId)
}
call.respond(HttpStatusCode.Created, mapOf("inventoryId" to newInventoryId))
}
}
// List all inventories with their assigned users
get("/api/admin/inventories") {
val principal = call.principal<UserPrincipal>()
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
if (!principal.isAdmin) {
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
return@get
}
call.respond(HttpStatusCode.OK, inventoryRepository.listInventoriesWithUsers())
}
}

View file

@ -20,16 +20,18 @@ internal fun Route.inventoryRoutes(
get {
val userId = call.principal<UserPrincipal>()?.userId
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventory = repository.loadInventory(userId)
val inventoryId = repository.getEffectiveInventoryId(userId)
val inventory = repository.loadInventory(inventoryId)
call.respond(HttpStatusCode.OK, inventory)
}
put {
val userId = call.principal<UserPrincipal>()?.userId
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventoryId = repository.getEffectiveInventoryId(userId)
val inventory = call.receive<InventoryDto>()
repository.saveInventory(userId, inventory)
val saved = repository.loadInventory(userId)
repository.saveInventory(inventoryId, inventory)
val saved = repository.loadInventory(inventoryId)
wsManager.notifyFullSyncRequired(userId)
call.respond(HttpStatusCode.OK, saved)
}
@ -37,11 +39,12 @@ internal fun Route.inventoryRoutes(
patch("/items/{id}") {
val userId = call.principal<UserPrincipal>()?.userId
?: return@patch call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventoryId = repository.getEffectiveInventoryId(userId)
val itemId = call.parameters["id"] ?: return@patch call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing item id")
)
val item = call.receive<ItemDto>()
val updated = repository.patchItem(userId, itemId, item)
val updated = repository.patchItem(inventoryId, itemId, item)
if (!updated) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Item not found"))
} else {
@ -51,3 +54,4 @@ internal fun Route.inventoryRoutes(
}
}
}

View file

@ -1,5 +1,383 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krisenvorrat Admin</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; }
header { background: #1a1a2e; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
header h1 { font-size: 1.2rem; }
#logout-btn { background: transparent; border: 1px solid #ccc; color: #ccc; padding: 6px 14px; border-radius: 4px; cursor: pointer; }
main { max-width: 960px; margin: 32px auto; padding: 0 16px; }
.card { background: #fff; border-radius: 8px; padding: 32px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 24px; }
h2 { margin-bottom: 20px; font-size: 1.1rem; }
label { display: block; margin-bottom: 4px; font-size: .875rem; color: #555; }
input, select { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 14px; font-size: 1rem; }
button.primary { background: #1a1a2e; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 1rem; }
button.primary:hover { background: #2d2d5e; }
button.danger { background: #c0392b; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
button.secondary { background: #eee; color: #333; border: 1px solid #ccc; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
button.info { background: #2980b9; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
.error { color: #c0392b; margin-bottom: 12px; font-size: .875rem; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: .9rem; }
th { background: #f9f9f9; font-weight: 600; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.inv-badge { display: inline-block; font-size: .75rem; background: #e8f4f8; color: #2980b9; border: 1px solid #aad4e8; border-radius: 4px; padding: 2px 7px; font-family: monospace; cursor: default; }
.inv-group { background: #f0f7ff; border: 1px solid #c3dff7; border-radius: 6px; padding: 14px 18px; margin-bottom: 12px; }
.inv-group h3 { font-size: .85rem; color: #555; margin-bottom: 8px; font-weight: 600; }
.inv-group ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.inv-group ul li { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 4px 10px; font-size: .875rem; }
#add-user-form { display: none; background: #f9f9f9; border: 1px solid #ddd; border-radius: 6px; padding: 20px; margin-top: 16px; }
#add-user-form.open { display: block; }
#add-user-form h3 { margin-bottom: 14px; font-size: 1rem; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: #fff; border-radius: 8px; padding: 28px; width: 400px; max-width: 95vw; }
.modal h3 { margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; }
</style>
</head>
<body>
<header>
<h1>Krisenvorrat Admin</h1>
<button id="logout-btn" style="display:none" onclick="logout()">Abmelden</button>
</header>
<main>
<section id="login-section" class="card">
<h2>Anmelden</h2>
<div id="login-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="login-username" type="text" autocomplete="username">
<label>Passwort</label>
<input id="login-password" type="password" autocomplete="current-password">
<button class="primary" onclick="login()">Anmelden</button>
</section>
<div id="admin-section" style="display:none">
<!-- User Management -->
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Benutzerverwaltung</h2>
<button class="primary" onclick="toggleAddForm()">+ Benutzer anlegen</button>
</div>
<div id="add-user-form">
<h3>Neuen Benutzer anlegen</h3>
<div id="add-user-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="new-username" type="text">
<label>Passwort</label>
<input id="new-password" type="password">
<div style="display:flex; gap:10px">
<button class="primary" onclick="createUser()">Anlegen</button>
<button class="secondary" onclick="toggleAddForm()">Abbrechen</button>
</div>
</div>
<table id="users-table">
<thead><tr><th>Benutzername</th><th>Erstellt</th><th>Admin</th><th>Inventar</th><th>Aktionen</th></tr></thead>
<tbody id="users-body"></tbody>
</table>
</section>
<!-- Inventory Overview -->
<section class="card">
<h2>Inventar-Übersicht</h2>
<div id="inventories-body"></div>
</section>
</div>
</main>
<!-- Change Password Modal -->
<div id="pw-modal" class="modal-overlay">
<div class="modal">
<h3>Passwort ändern</h3>
<div id="pw-error" class="error" style="display:none"></div>
<label>Neues Passwort</label>
<input id="pw-input" type="password">
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmPasswordChange()">Speichern</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div id="del-modal" class="modal-overlay">
<div class="modal">
<h3>Benutzer löschen?</h3>
<p id="del-confirm-text" style="margin-bottom:16px; font-size:.9rem; color:#555"></p>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="danger" onclick="confirmDelete()">Löschen</button>
</div>
</div>
</div>
<!-- Assign Inventory Modal -->
<div id="assign-modal" class="modal-overlay">
<div class="modal">
<h3>Inventar zuweisen</h3>
<p style="margin-bottom:14px; font-size:.875rem; color:#555">Wähle ein bestehendes Inventar für diesen Benutzer:</p>
<div id="assign-error" class="error" style="display:none"></div>
<label>Inventar</label>
<select id="assign-inventory-select"></select>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmAssign()">Zuweisen</button>
</div>
</div>
</div>
<script>
let accessToken = sessionStorage.getItem('accessToken') || '';
let pendingUserId = null;
let allInventories = [];
if (accessToken) tryLoadUsers();
async function login() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const err = document.getElementById('login-error');
err.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Login fehlgeschlagen';
err.style.display = 'block';
return;
}
const data = await res.json();
accessToken = data.accessToken;
sessionStorage.setItem('accessToken', accessToken);
showAdmin();
} catch (e) {
err.textContent = 'Verbindungsfehler';
err.style.display = 'block';
}
}
function showAdmin() {
document.getElementById('login-section').style.display = 'none';
document.getElementById('admin-section').style.display = 'block';
document.getElementById('logout-btn').style.display = 'inline-block';
loadAll();
}
function tryLoadUsers() {
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } })
.then(r => r.ok ? (showAdmin(), r.json()) : Promise.reject())
.then(renderUsers)
.catch(() => { accessToken = ''; sessionStorage.removeItem('accessToken'); });
}
async function loadAll() {
const [usersRes, inventoriesRes] = await Promise.all([
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } })
]);
if (!usersRes.ok) { logout(); return; }
renderUsers(await usersRes.json());
if (inventoriesRes.ok) {
allInventories = await inventoriesRes.json();
renderInventories(allInventories);
}
}
function renderUsers(users) {
const tbody = document.getElementById('users-body');
tbody.innerHTML = '';
users.forEach(u => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = u.username;
const tdDate = document.createElement('td'); tdDate.textContent = new Date(u.createdAt).toLocaleDateString('de-DE');
const tdAdmin = document.createElement('td'); tdAdmin.textContent = u.isAdmin ? '✓' : '';
const tdInv = document.createElement('td');
if (u.inventoryId) {
const badge = document.createElement('span');
badge.className = 'inv-badge';
badge.title = u.inventoryId;
badge.textContent = u.inventoryId.slice(0, 8) + '…';
tdInv.appendChild(badge);
} else {
tdInv.textContent = '—';
}
const tdActions = document.createElement('td'); tdActions.className = 'actions';
const btnPw = document.createElement('button'); btnPw.className = 'secondary'; btnPw.textContent = 'PW ändern'; btnPw.onclick = () => openPasswordModal(u.id);
const btnAssign = document.createElement('button'); btnAssign.className = 'info'; btnAssign.textContent = 'Inventar wechseln'; btnAssign.onclick = () => openAssignModal(u.id);
const btnNew = document.createElement('button'); btnNew.className = 'secondary'; btnNew.textContent = 'Neues Inventar'; btnNew.onclick = () => createNewInventory(u.id);
const btnDel = document.createElement('button'); btnDel.className = 'danger'; btnDel.textContent = 'Löschen'; btnDel.onclick = () => openDeleteModal(u.id, u.username);
tdActions.append(btnPw, btnAssign, btnNew, btnDel);
tr.append(tdName, tdDate, tdAdmin, tdInv, tdActions);
tbody.appendChild(tr);
});
}
function renderInventories(inventories) {
const container = document.getElementById('inventories-body');
container.innerHTML = '';
if (!inventories.length) {
container.textContent = 'Keine Inventare vorhanden.';
return;
}
inventories.forEach(inv => {
const div = document.createElement('div'); div.className = 'inv-group';
const h3 = document.createElement('h3');
h3.textContent = 'Inventar ' + inv.inventoryId.slice(0, 8) + '… (' + inv.users.length + ' ' + (inv.users.length === 1 ? 'Benutzer' : 'Benutzer') + ')';
const ul = document.createElement('ul');
if (inv.users.length === 0) {
const li = document.createElement('li'); li.textContent = '(keine Benutzer)'; ul.appendChild(li);
} else {
inv.users.forEach(u => {
const li = document.createElement('li'); li.textContent = u.username + (u.isAdmin ? ' [Admin]' : ''); ul.appendChild(li);
});
}
div.append(h3, ul);
container.appendChild(div);
});
}
function toggleAddForm() {
document.getElementById('add-user-form').classList.toggle('open');
}
async function createUser() {
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const err = document.getElementById('add-user-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
toggleAddForm();
loadAll();
}
function openPasswordModal(userId) {
pendingUserId = userId;
document.getElementById('pw-input').value = '';
document.getElementById('pw-error').style.display = 'none';
document.getElementById('pw-modal').classList.add('open');
}
async function confirmPasswordChange() {
const password = document.getElementById('pw-input').value;
const err = document.getElementById('pw-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
}
function openDeleteModal(userId, username) {
pendingUserId = userId;
document.getElementById('del-confirm-text').textContent = `Benutzer "${username}" wirklich löschen?`;
document.getElementById('del-modal').classList.add('open');
}
async function confirmDelete() {
await fetch('/api/admin/users/' + pendingUserId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
closeModal();
loadAll();
}
function openAssignModal(userId) {
pendingUserId = userId;
const select = document.getElementById('assign-inventory-select');
select.innerHTML = '';
allInventories.forEach(inv => {
const opt = document.createElement('option');
opt.value = inv.inventoryId;
const names = inv.users.map(u => u.username).join(', ') || '(leer)';
opt.textContent = inv.inventoryId.slice(0, 8) + '… ' + names;
select.appendChild(opt);
});
document.getElementById('assign-error').style.display = 'none';
document.getElementById('assign-modal').classList.add('open');
}
async function confirmAssign() {
const inventoryId = document.getElementById('assign-inventory-select').value;
const err = document.getElementById('assign-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId + '/inventory', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ inventoryId })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
loadAll();
}
async function createNewInventory(userId) {
if (!confirm('Für diesen Benutzer ein neues, leeres Inventar erstellen?')) return;
await fetch('/api/admin/users/' + userId + '/inventory/new', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
loadAll();
}
function closeModal() {
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
pendingUserId = null;
}
function logout() {
accessToken = '';
sessionStorage.removeItem('accessToken');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-section').style.display = 'block';
document.getElementById('admin-section').style.display = 'none';
document.getElementById('logout-btn').style.display = 'none';
}
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
</script>
<footer>Krisenvorrat Server v0.2 &middot; &copy; 2026 faenocasul</footer>
</body>
</html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

View file

@ -0,0 +1,295 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.model.AssignInventoryRequest
import de.krisenvorrat.server.model.CreateUserRequest
import de.krisenvorrat.server.model.InventoryWithUsersDto
import de.krisenvorrat.server.model.UserDto
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.testing.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class InventorySharingTest {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val adminToken = createTestAccessToken(
userId = TEST_ADMIN_ID,
username = TEST_ADMIN_USERNAME,
isAdmin = true
)
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:sharing_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
// --- Helper: create a user and return their JWT token + userId ---
private suspend fun ApplicationTestBuilder.createUserAndGetToken(
username: String, password: String = "pass123"
): Pair<String, String> {
// Create via admin API
client.post("/api/admin/users") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(CreateUserRequest(username, password)))
}
// Login to get token
val loginRes = client.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(mapOf("username" to username, "password" to password)))
}
val loginBody = json.parseToJsonElement(loginRes.bodyAsText())
val token = loginBody.let {
(it as kotlinx.serialization.json.JsonObject)["accessToken"]!!.let { v ->
(v as kotlinx.serialization.json.JsonPrimitive).content
}
}
val userId = loginBody.let {
(it as kotlinx.serialization.json.JsonObject)["userId"]!!.let { v ->
(v as kotlinx.serialization.json.JsonPrimitive).content
}
}
return Pair(token, userId)
}
// --- Tests ---
@Test
fun test_newUser_getsOwnInventory() = testApp {
val (tokenA, _) = createUserAndGetToken("alice")
val (tokenB, _) = createUserAndGetToken("bob")
// Alice stores an item
val inventoryA = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(makeItem("item-a1", "Dosenbrot")),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(tokenA)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventoryA))
}
// Bob sees empty inventory (separate inventory by default)
val res = client.get("/api/inventory") { bearerAuth(tokenB) }
assertEquals(HttpStatusCode.OK, res.status)
val bobInventory = json.decodeFromString<InventoryDto>(res.bodyAsText())
assertTrue("Bob must not see Alice's items", bobInventory.items.isEmpty())
}
@Test
fun test_assignSharedInventory_bothUsersSeesSameData() = testApp {
val (tokenA, userIdA) = createUserAndGetToken("alice2")
val (tokenB, userIdB) = createUserAndGetToken("bob2")
// Alice stores an item in her inventory
val inventoryA = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(makeItem("item-a1", "Dosenbrot")),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(tokenA)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventoryA))
}
// Get Alice's inventoryId from admin user list
val usersRes = client.get("/api/admin/users") { bearerAuth(adminToken) }
val users = json.decodeFromString<List<UserDto>>(usersRes.bodyAsText())
val aliceDto = users.find { it.username == "alice2" }
assertNotNull("Alice must exist", aliceDto)
val aliceInventoryId = aliceDto!!.inventoryId
assertNotNull("Alice must have an inventoryId", aliceInventoryId)
// Assign Bob to Alice's inventory
val assignRes = client.put("/api/admin/users/$userIdB/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(aliceInventoryId!!)))
}
assertEquals(HttpStatusCode.OK, assignRes.status)
// Bob now sees Alice's inventory
val bobRes = client.get("/api/inventory") { bearerAuth(tokenB) }
assertEquals(HttpStatusCode.OK, bobRes.status)
val bobInventory = json.decodeFromString<InventoryDto>(bobRes.bodyAsText())
assertEquals(1, bobInventory.items.size)
assertEquals("Dosenbrot", bobInventory.items[0].name)
}
@Test
fun test_sharedInventory_bobCanWrite_aliceSeesChange() = testApp {
val (tokenA, userIdA) = createUserAndGetToken("alice3")
val (tokenB, userIdB) = createUserAndGetToken("bob3")
// Get Alice's inventoryId
val usersRes = client.get("/api/admin/users") { bearerAuth(adminToken) }
val users = json.decodeFromString<List<UserDto>>(usersRes.bodyAsText())
val aliceInventoryId = users.find { it.username == "alice3" }!!.inventoryId!!
// Assign Bob to Alice's inventory
client.put("/api/admin/users/$userIdB/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(aliceInventoryId)))
}
// Bob writes to the shared inventory
val sharedInventory = InventoryDto(
categories = listOf(CategoryDto(id = 2, name = "Getränke")),
locations = listOf(LocationDto(id = 2, name = "Keller")),
items = listOf(makeItem("item-b1", "Wasser")),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(tokenB)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), sharedInventory))
}
// Alice sees Bob's changes
val aliceRes = client.get("/api/inventory") { bearerAuth(tokenA) }
assertEquals(HttpStatusCode.OK, aliceRes.status)
val aliceInventory = json.decodeFromString<InventoryDto>(aliceRes.bodyAsText())
assertEquals(1, aliceInventory.items.size)
assertEquals("Wasser", aliceInventory.items[0].name)
}
@Test
fun test_createNewInventory_disconnectsFromShared() = testApp {
val (_, userIdA) = createUserAndGetToken("alice4")
val (tokenB, userIdB) = createUserAndGetToken("bob4")
// Get Alice's inventoryId and assign Bob to it
val users = json.decodeFromString<List<UserDto>>(
client.get("/api/admin/users") { bearerAuth(adminToken) }.bodyAsText()
)
val aliceInventoryId = users.find { it.username == "alice4" }!!.inventoryId!!
client.put("/api/admin/users/$userIdB/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(aliceInventoryId)))
}
// Create a new inventory for Bob (disconnect)
val newInvRes = client.post("/api/admin/users/$userIdB/inventory/new") {
bearerAuth(adminToken)
}
assertEquals(HttpStatusCode.Created, newInvRes.status)
// Bob should no longer be in Alice's inventory group
val inventoriesRes = client.get("/api/admin/inventories") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.OK, inventoriesRes.status)
val inventories = json.decodeFromString<List<InventoryWithUsersDto>>(inventoriesRes.bodyAsText())
val aliceGroup = inventories.find { it.inventoryId == aliceInventoryId }
assertNotNull(aliceGroup)
assertTrue("Bob must not be in Alice's group", aliceGroup!!.users.none { it.username == "bob4" })
}
@Test
fun test_deleteUser_orphanedEmptyInventoryIsRemoved() = testApp {
val (_, userIdC) = createUserAndGetToken("charlie")
// Get Charlie's inventoryId
val users = json.decodeFromString<List<UserDto>>(
client.get("/api/admin/users") { bearerAuth(adminToken) }.bodyAsText()
)
val charlieInventoryId = users.find { it.username == "charlie" }!!.inventoryId!!
// Delete Charlie
val deleteRes = client.delete("/api/admin/users/$userIdC") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.NoContent, deleteRes.status)
// Charlie's empty inventory should be cleaned up
val inventories = json.decodeFromString<List<InventoryWithUsersDto>>(
client.get("/api/admin/inventories") { bearerAuth(adminToken) }.bodyAsText()
)
assertTrue(
"Charlie's orphaned inventory must be deleted",
inventories.none { it.inventoryId == charlieInventoryId }
)
}
@Test
fun test_listInventories_asAdmin_returnsAllInventories() = testApp {
createUserAndGetToken("diana")
createUserAndGetToken("eve")
val res = client.get("/api/admin/inventories") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.OK, res.status)
val inventories = json.decodeFromString<List<InventoryWithUsersDto>>(res.bodyAsText())
// admin + diana + eve → at least 3 inventories
assertTrue(inventories.size >= 3)
}
@Test
fun test_assignInventory_nonAdmin_returns403() = testApp {
val (tokenA, userIdA) = createUserAndGetToken("frank")
val users = json.decodeFromString<List<UserDto>>(
client.get("/api/admin/users") { bearerAuth(adminToken) }.bodyAsText()
)
val frankInventoryId = users.find { it.username == "frank" }!!.inventoryId!!
val res = client.put("/api/admin/users/$userIdA/inventory") {
bearerAuth(tokenA) // non-admin token
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(frankInventoryId)))
}
assertEquals(HttpStatusCode.Forbidden, res.status)
}
@Test
fun test_assignInventory_nonExistentInventory_returns404() = testApp {
val (_, userIdA) = createUserAndGetToken("grace")
val res = client.put("/api/admin/users/$userIdA/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest("non-existent-inventory-id")))
}
assertEquals(HttpStatusCode.NotFound, res.status)
}
private fun makeItem(id: String, name: String) = ItemDto(
id = id,
name = name,
categoryId = 1,
quantity = 1.0,
unit = "Stück",
unitPrice = 1.99,
kcalPerKg = null,
expiryDate = null,
locationId = 1,
notes = "",
lastUpdated = System.currentTimeMillis()
)
}