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:
parent
8576fffdb7
commit
c03475e7e5
10 changed files with 919 additions and 31 deletions
|
|
@ -3,6 +3,7 @@ package de.krisenvorrat.server.db
|
||||||
import org.jetbrains.exposed.sql.Database
|
import org.jetbrains.exposed.sql.Database
|
||||||
import org.jetbrains.exposed.sql.SchemaUtils
|
import org.jetbrains.exposed.sql.SchemaUtils
|
||||||
import org.jetbrains.exposed.sql.insert
|
import org.jetbrains.exposed.sql.insert
|
||||||
|
import org.jetbrains.exposed.sql.update
|
||||||
import org.jetbrains.exposed.sql.selectAll
|
import org.jetbrains.exposed.sql.selectAll
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.mindrot.jbcrypt.BCrypt
|
import org.mindrot.jbcrypt.BCrypt
|
||||||
|
|
@ -20,12 +21,47 @@ internal object DatabaseFactory {
|
||||||
) {
|
) {
|
||||||
Database.connect(jdbcUrl, driver)
|
Database.connect(jdbcUrl, driver)
|
||||||
transaction {
|
transaction {
|
||||||
SchemaUtils.create(Users, Categories, Locations, Items, Settings, Messages)
|
SchemaUtils.create(Inventories, Users, Categories, Locations, Items, Settings, Messages)
|
||||||
SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings, Messages)
|
SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages)
|
||||||
}
|
}
|
||||||
|
migrateUserInventories()
|
||||||
seedAdmin(adminPassword)
|
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?) {
|
private fun seedAdmin(adminPassword: String?) {
|
||||||
transaction {
|
transaction {
|
||||||
val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0
|
val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0
|
||||||
|
|
@ -40,12 +76,19 @@ internal object DatabaseFactory {
|
||||||
logger.warn("==============================================")
|
logger.warn("==============================================")
|
||||||
generated
|
generated
|
||||||
}
|
}
|
||||||
|
val adminId = UUID.randomUUID().toString()
|
||||||
|
val inventoryId = UUID.randomUUID().toString()
|
||||||
|
Inventories.insert {
|
||||||
|
it[id] = inventoryId
|
||||||
|
it[createdAt] = System.currentTimeMillis()
|
||||||
|
}
|
||||||
Users.insert {
|
Users.insert {
|
||||||
it[id] = UUID.randomUUID().toString()
|
it[id] = adminId
|
||||||
it[username] = "admin"
|
it[username] = "admin"
|
||||||
it[passwordHash] = BCrypt.hashpw(password, BCrypt.gensalt())
|
it[passwordHash] = BCrypt.hashpw(password, BCrypt.gensalt())
|
||||||
it[createdAt] = System.currentTimeMillis()
|
it[createdAt] = System.currentTimeMillis()
|
||||||
it[isAdmin] = true
|
it[isAdmin] = true
|
||||||
|
it[Users.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,19 @@ package de.krisenvorrat.server.db
|
||||||
|
|
||||||
import org.jetbrains.exposed.sql.Table
|
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") {
|
internal object Users : Table("users") {
|
||||||
val id = varchar("id", 36)
|
val id = varchar("id", 36)
|
||||||
val username = varchar("username", 255).uniqueIndex()
|
val username = varchar("username", 255).uniqueIndex()
|
||||||
val passwordHash = varchar("password_hash", 255)
|
val passwordHash = varchar("password_hash", 255)
|
||||||
val createdAt = long("created_at")
|
val createdAt = long("created_at")
|
||||||
val isAdmin = bool("is_admin").default(false)
|
val isAdmin = bool("is_admin").default(false)
|
||||||
|
val inventoryId = varchar("inventory_id", 36).nullable()
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
@ -16,7 +23,7 @@ internal object Categories : Table("categories") {
|
||||||
val pk = integer("pk").autoIncrement()
|
val pk = integer("pk").autoIncrement()
|
||||||
val id = integer("id")
|
val id = integer("id")
|
||||||
val name = varchar("name", 255)
|
val name = varchar("name", 255)
|
||||||
val userId = varchar("user_id", 36).nullable()
|
val inventoryId = varchar("inventory_id", 36).nullable()
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(pk)
|
override val primaryKey = PrimaryKey(pk)
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +32,7 @@ internal object Locations : Table("locations") {
|
||||||
val pk = integer("pk").autoIncrement()
|
val pk = integer("pk").autoIncrement()
|
||||||
val id = integer("id")
|
val id = integer("id")
|
||||||
val name = varchar("name", 255)
|
val name = varchar("name", 255)
|
||||||
val userId = varchar("user_id", 36).nullable()
|
val inventoryId = varchar("inventory_id", 36).nullable()
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(pk)
|
override val primaryKey = PrimaryKey(pk)
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +49,7 @@ internal object Items : Table("items") {
|
||||||
val locationId = integer("location_id")
|
val locationId = integer("location_id")
|
||||||
val notes = text("notes")
|
val notes = text("notes")
|
||||||
val lastUpdated = long("last_updated")
|
val lastUpdated = long("last_updated")
|
||||||
val userId = varchar("user_id", 36).nullable()
|
val inventoryId = varchar("inventory_id", 36).nullable()
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +58,7 @@ internal object Settings : Table("settings") {
|
||||||
val id = integer("id").autoIncrement()
|
val id = integer("id").autoIncrement()
|
||||||
val key = varchar("key", 255)
|
val key = varchar("key", 255)
|
||||||
val value = text("value")
|
val value = text("value")
|
||||||
val userId = varchar("user_id", 36).nullable()
|
val inventoryId = varchar("inventory_id", 36).nullable()
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ internal data class UserDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val isAdmin: Boolean
|
val isAdmin: Boolean,
|
||||||
|
val inventoryId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -29,3 +30,13 @@ internal data class CreateUserRequest(val username: String, val password: String
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class UpdatePasswordRequest(val password: String)
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ internal fun Application.configureRouting(
|
||||||
// Protected endpoints
|
// Protected endpoints
|
||||||
authenticate("auth-jwt") {
|
authenticate("auth-jwt") {
|
||||||
inventoryRoutes(inventoryRepository, wsManager)
|
inventoryRoutes(inventoryRepository, wsManager)
|
||||||
adminRoutes(userRepository)
|
adminRoutes(userRepository, inventoryRepository)
|
||||||
messageRoutes(messageRepository, userRepository, wsManager)
|
messageRoutes(messageRepository, userRepository, wsManager)
|
||||||
userRoutes(userRepository)
|
userRoutes(userRepository)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
package de.krisenvorrat.server.repository
|
package de.krisenvorrat.server.repository
|
||||||
|
|
||||||
import de.krisenvorrat.server.db.Categories
|
import de.krisenvorrat.server.db.Categories
|
||||||
|
import de.krisenvorrat.server.db.Inventories
|
||||||
import de.krisenvorrat.server.db.Items
|
import de.krisenvorrat.server.db.Items
|
||||||
import de.krisenvorrat.server.db.Locations
|
import de.krisenvorrat.server.db.Locations
|
||||||
import de.krisenvorrat.server.db.Settings
|
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.CategoryDto
|
||||||
import de.krisenvorrat.shared.model.InventoryDto
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
import de.krisenvorrat.shared.model.ItemDto
|
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.selectAll
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.jetbrains.exposed.sql.update
|
import org.jetbrains.exposed.sql.update
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
internal class InventoryRepository {
|
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 {
|
transaction {
|
||||||
Items.deleteWhere { Items.userId eq userId }
|
Inventories.insert {
|
||||||
Settings.deleteWhere { Settings.userId eq userId }
|
it[id] = inventoryId
|
||||||
Categories.deleteWhere { Categories.userId eq userId }
|
it[createdAt] = System.currentTimeMillis()
|
||||||
Locations.deleteWhere { Locations.userId eq userId }
|
}
|
||||||
|
}
|
||||||
|
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) {
|
for (category in inventory.categories) {
|
||||||
Categories.insert {
|
Categories.insert {
|
||||||
it[id] = category.id
|
it[id] = category.id
|
||||||
it[name] = category.name
|
it[name] = category.name
|
||||||
it[Categories.userId] = userId
|
it[Categories.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,7 +117,7 @@ internal class InventoryRepository {
|
||||||
Locations.insert {
|
Locations.insert {
|
||||||
it[id] = location.id
|
it[id] = location.id
|
||||||
it[name] = location.name
|
it[name] = location.name
|
||||||
it[Locations.userId] = userId
|
it[Locations.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +134,7 @@ internal class InventoryRepository {
|
||||||
it[locationId] = item.locationId
|
it[locationId] = item.locationId
|
||||||
it[notes] = item.notes
|
it[notes] = item.notes
|
||||||
it[lastUpdated] = item.lastUpdated
|
it[lastUpdated] = item.lastUpdated
|
||||||
it[Items.userId] = userId
|
it[Items.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,24 +142,24 @@ internal class InventoryRepository {
|
||||||
Settings.insert {
|
Settings.insert {
|
||||||
it[key] = setting.key
|
it[key] = setting.key
|
||||||
it[value] = setting.value
|
it[value] = setting.value
|
||||||
it[Settings.userId] = userId
|
it[Settings.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadInventory(userId: String): InventoryDto {
|
fun loadInventory(inventoryId: String): InventoryDto {
|
||||||
return transaction {
|
return transaction {
|
||||||
val categories = Categories.selectAll()
|
val categories = Categories.selectAll()
|
||||||
.where { Categories.userId eq userId }
|
.where { Categories.inventoryId eq inventoryId }
|
||||||
.map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) }
|
.map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) }
|
||||||
|
|
||||||
val locations = Locations.selectAll()
|
val locations = Locations.selectAll()
|
||||||
.where { Locations.userId eq userId }
|
.where { Locations.inventoryId eq inventoryId }
|
||||||
.map { LocationDto(id = it[Locations.id], name = it[Locations.name]) }
|
.map { LocationDto(id = it[Locations.id], name = it[Locations.name]) }
|
||||||
|
|
||||||
val items = Items.selectAll()
|
val items = Items.selectAll()
|
||||||
.where { Items.userId eq userId }
|
.where { Items.inventoryId eq inventoryId }
|
||||||
.map {
|
.map {
|
||||||
ItemDto(
|
ItemDto(
|
||||||
id = it[Items.id],
|
id = it[Items.id],
|
||||||
|
|
@ -98,17 +177,17 @@ internal class InventoryRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
val settings = Settings.selectAll()
|
val settings = Settings.selectAll()
|
||||||
.where { Settings.userId eq userId }
|
.where { Settings.inventoryId eq inventoryId }
|
||||||
.map { SettingDto(key = it[Settings.key], value = it[Settings.value]) }
|
.map { SettingDto(key = it[Settings.key], value = it[Settings.value]) }
|
||||||
|
|
||||||
InventoryDto(categories = categories, locations = locations, items = items, settings = settings)
|
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 {
|
return transaction {
|
||||||
val updated = Items.update({
|
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[name] = item.name
|
||||||
it[categoryId] = item.categoryId
|
it[categoryId] = item.categoryId
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.krisenvorrat.server.repository
|
package de.krisenvorrat.server.repository
|
||||||
|
|
||||||
|
import de.krisenvorrat.server.db.Inventories
|
||||||
import de.krisenvorrat.server.db.Users
|
import de.krisenvorrat.server.db.Users
|
||||||
import de.krisenvorrat.server.model.UserDto
|
import de.krisenvorrat.server.model.UserDto
|
||||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
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.selectAll
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.jetbrains.exposed.sql.update
|
import org.jetbrains.exposed.sql.update
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
internal data class UserRow(
|
internal data class UserRow(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|
@ -21,12 +23,18 @@ internal class UserRepository {
|
||||||
|
|
||||||
fun create(id: String, username: String, passwordHash: String, isAdmin: Boolean = false): Boolean {
|
fun create(id: String, username: String, passwordHash: String, isAdmin: Boolean = false): Boolean {
|
||||||
return transaction {
|
return transaction {
|
||||||
|
val inventoryId = UUID.randomUUID().toString()
|
||||||
|
Inventories.insert {
|
||||||
|
it[Inventories.id] = inventoryId
|
||||||
|
it[Inventories.createdAt] = System.currentTimeMillis()
|
||||||
|
}
|
||||||
val inserted = Users.insert {
|
val inserted = Users.insert {
|
||||||
it[Users.id] = id
|
it[Users.id] = id
|
||||||
it[Users.username] = username
|
it[Users.username] = username
|
||||||
it[Users.passwordHash] = passwordHash
|
it[Users.passwordHash] = passwordHash
|
||||||
it[Users.createdAt] = System.currentTimeMillis()
|
it[Users.createdAt] = System.currentTimeMillis()
|
||||||
it[Users.isAdmin] = isAdmin
|
it[Users.isAdmin] = isAdmin
|
||||||
|
it[Users.inventoryId] = inventoryId
|
||||||
}.insertedCount
|
}.insertedCount
|
||||||
inserted > 0
|
inserted > 0
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +60,8 @@ internal class UserRepository {
|
||||||
id = it[Users.id],
|
id = it[Users.id],
|
||||||
username = it[Users.username],
|
username = it[Users.username],
|
||||||
createdAt = it[Users.createdAt],
|
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]
|
isAdmin = this[Users.isAdmin]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package de.krisenvorrat.server.routes
|
package de.krisenvorrat.server.routes
|
||||||
|
|
||||||
|
import de.krisenvorrat.server.model.AssignInventoryRequest
|
||||||
import de.krisenvorrat.server.model.CreateUserRequest
|
import de.krisenvorrat.server.model.CreateUserRequest
|
||||||
import de.krisenvorrat.server.model.ErrorResponse
|
import de.krisenvorrat.server.model.ErrorResponse
|
||||||
import de.krisenvorrat.server.model.UpdatePasswordRequest
|
import de.krisenvorrat.server.model.UpdatePasswordRequest
|
||||||
|
import de.krisenvorrat.server.repository.InventoryRepository
|
||||||
import de.krisenvorrat.server.repository.UserRepository
|
import de.krisenvorrat.server.repository.UserRepository
|
||||||
import de.krisenvorrat.server.security.UserPrincipal
|
import de.krisenvorrat.server.security.UserPrincipal
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
|
@ -13,7 +15,7 @@ import io.ktor.server.routing.*
|
||||||
import org.mindrot.jbcrypt.BCrypt
|
import org.mindrot.jbcrypt.BCrypt
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
internal fun Route.adminRoutes(userRepository: UserRepository) {
|
internal fun Route.adminRoutes(userRepository: UserRepository, inventoryRepository: InventoryRepository) {
|
||||||
route("/api/admin/users") {
|
route("/api/admin/users") {
|
||||||
get {
|
get {
|
||||||
val principal = call.principal<UserPrincipal>()
|
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"))
|
call.respond(HttpStatusCode.Conflict, ErrorResponse(status = 409, message = "Cannot delete your own account"))
|
||||||
return@delete
|
return@delete
|
||||||
}
|
}
|
||||||
|
val userDto = userRepository.listAll().find { it.id == id }
|
||||||
|
val oldInventoryId = userDto?.inventoryId
|
||||||
val deleted = userRepository.deleteById(id)
|
val deleted = userRepository.deleteById(id)
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "User not found"))
|
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "User not found"))
|
||||||
} else {
|
} else {
|
||||||
|
if (oldInventoryId != null) {
|
||||||
|
inventoryRepository.cleanupOrphanedInventory(oldInventoryId)
|
||||||
|
}
|
||||||
call.respond(HttpStatusCode.NoContent)
|
call.respond(HttpStatusCode.NoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,5 +98,59 @@ internal fun Route.adminRoutes(userRepository: UserRepository) {
|
||||||
call.respond(HttpStatusCode.OK, mapOf("message" to "Password updated"))
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,18 @@ internal fun Route.inventoryRoutes(
|
||||||
get {
|
get {
|
||||||
val userId = call.principal<UserPrincipal>()?.userId
|
val userId = call.principal<UserPrincipal>()?.userId
|
||||||
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
?: 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)
|
call.respond(HttpStatusCode.OK, inventory)
|
||||||
}
|
}
|
||||||
|
|
||||||
put {
|
put {
|
||||||
val userId = call.principal<UserPrincipal>()?.userId
|
val userId = call.principal<UserPrincipal>()?.userId
|
||||||
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
||||||
|
val inventoryId = repository.getEffectiveInventoryId(userId)
|
||||||
val inventory = call.receive<InventoryDto>()
|
val inventory = call.receive<InventoryDto>()
|
||||||
repository.saveInventory(userId, inventory)
|
repository.saveInventory(inventoryId, inventory)
|
||||||
val saved = repository.loadInventory(userId)
|
val saved = repository.loadInventory(inventoryId)
|
||||||
wsManager.notifyFullSyncRequired(userId)
|
wsManager.notifyFullSyncRequired(userId)
|
||||||
call.respond(HttpStatusCode.OK, saved)
|
call.respond(HttpStatusCode.OK, saved)
|
||||||
}
|
}
|
||||||
|
|
@ -37,11 +39,12 @@ internal fun Route.inventoryRoutes(
|
||||||
patch("/items/{id}") {
|
patch("/items/{id}") {
|
||||||
val userId = call.principal<UserPrincipal>()?.userId
|
val userId = call.principal<UserPrincipal>()?.userId
|
||||||
?: return@patch call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
?: 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(
|
val itemId = call.parameters["id"] ?: return@patch call.respond(
|
||||||
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing item id")
|
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing item id")
|
||||||
)
|
)
|
||||||
val item = call.receive<ItemDto>()
|
val item = call.receive<ItemDto>()
|
||||||
val updated = repository.patchItem(userId, itemId, item)
|
val updated = repository.patchItem(inventoryId, itemId, item)
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Item not found"))
|
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Item not found"))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -51,3 +54,4 @@ internal fun Route.inventoryRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,383 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<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 · © 2026 faenocasul</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue