feat: Admin-UI Tab-Navigation + Backups-Endpoint (#90)

- Tab-Leiste mit drei Tabs: User, Inventare, Backups
- Aktiver Tab visuell hervorgehoben, nur aktiver Inhalt sichtbar
- Default-Tab: User
- Neuer GET /api/admin/backups Endpoint (JWT-geschützt)
  → listet .sql.gz-Dateien aus /backups (name, sizeBytes, createdAt)
  → absteigend nach Datum sortiert
- Backups-Tab: Tabelle mit Dateiname, Größe (human-readable), Erstellt
  → Refresh-Button, Hinweis bei leerem Verzeichnis
- docker-compose.yml: backup_data:/backups:ro Mount im Server-Container
- 4 neue Tests (Admin-Backups: 200, 403, 401, Dateiliste sortiert)
This commit is contained in:
Jens Reinemann 2026-05-17 12:00:54 +02:00
parent 9004baede1
commit 0fee89ec32
7 changed files with 205 additions and 5 deletions

View file

@ -23,6 +23,8 @@ services:
- KRISENVORRAT_DB_URL=jdbc:postgresql://db:5432/krisenvorrat - KRISENVORRAT_DB_URL=jdbc:postgresql://db:5432/krisenvorrat
- KRISENVORRAT_DB_USER=krisenvorrat - KRISENVORRAT_DB_USER=krisenvorrat
- KRISENVORRAT_DB_PASSWORD=krisenvorrat - KRISENVORRAT_DB_PASSWORD=krisenvorrat
volumes:
- backup_data:/backups:ro
depends_on: depends_on:
- db - db

View file

@ -9,6 +9,7 @@ import de.krisenvorrat.server.plugins.configureSerialization
import de.krisenvorrat.server.plugins.configureStatusPages import de.krisenvorrat.server.plugins.configureStatusPages
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.netty.* import io.ktor.server.netty.*
import java.io.File
private const val DEFAULT_JWT_SECRET = "change-me-to-a-secure-jwt-secret-at-least-32-chars" private const val DEFAULT_JWT_SECRET = "change-me-to-a-secure-jwt-secret-at-least-32-chars"
@ -25,11 +26,11 @@ internal fun Application.module() {
configurePlugins() configurePlugins()
} }
internal fun Application.configurePlugins() { internal fun Application.configurePlugins(backupDir: File = File("/backups")) {
configureSerialization() configureSerialization()
configureStatusPages() configureStatusPages()
configureCallLogging() configureCallLogging()
configureRateLimiting() configureRateLimiting()
configureAuthentication() configureAuthentication()
configureRouting() configureRouting(backupDir = backupDir)
} }

View file

@ -0,0 +1,10 @@
package de.krisenvorrat.server.model
import kotlinx.serialization.Serializable
@Serializable
internal data class BackupFileInfo(
val name: String,
val sizeBytes: Long,
val createdAt: String
)

View file

@ -33,7 +33,8 @@ internal fun Application.configureRouting(
userRepository: UserRepository = UserRepository(), userRepository: UserRepository = UserRepository(),
messageRepository: MessageRepository = MessageRepository(), messageRepository: MessageRepository = MessageRepository(),
jwtService: JwtService = JwtService(environment.config), jwtService: JwtService = JwtService(environment.config),
wsManager: WebSocketManager = WebSocketManager() wsManager: WebSocketManager = WebSocketManager(),
backupDir: File = File("/backups")
) { ) {
val config = environment.config val config = environment.config
val appVersionCode = config.propertyOrNull("krisenvorrat.appVersionCode") val appVersionCode = config.propertyOrNull("krisenvorrat.appVersionCode")
@ -79,7 +80,7 @@ internal fun Application.configureRouting(
inventoryRoutes(inventoryRepository, wsManager) inventoryRoutes(inventoryRepository, wsManager)
} }
rateLimit(RATE_LIMIT_ADMIN) { rateLimit(RATE_LIMIT_ADMIN) {
adminRoutes(userRepository, inventoryRepository) adminRoutes(userRepository, inventoryRepository, backupDir)
} }
rateLimit(RATE_LIMIT_MESSAGES) { rateLimit(RATE_LIMIT_MESSAGES) {
messageRoutes(messageRepository, userRepository, wsManager) messageRoutes(messageRepository, userRepository, wsManager)

View file

@ -1,6 +1,7 @@
package de.krisenvorrat.server.routes package de.krisenvorrat.server.routes
import de.krisenvorrat.server.model.AssignInventoryRequest import de.krisenvorrat.server.model.AssignInventoryRequest
import de.krisenvorrat.server.model.BackupFileInfo
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
@ -13,9 +14,17 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import java.io.File
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
internal fun Route.adminRoutes(userRepository: UserRepository, inventoryRepository: InventoryRepository) { internal fun Route.adminRoutes(
userRepository: UserRepository,
inventoryRepository: InventoryRepository,
backupDir: File = File("/backups")
) {
route("/api/admin/users") { route("/api/admin/users") {
get { get {
val principal = call.principal<UserPrincipal>() val principal = call.principal<UserPrincipal>()
@ -186,5 +195,31 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito
} }
call.respond(HttpStatusCode.OK, inventoryRepository.getStatsPerInventory()) call.respond(HttpStatusCode.OK, inventoryRepository.getStatsPerInventory())
} }
get("/api/admin/backups") {
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
}
val files = if (backupDir.isDirectory) {
backupDir.listFiles { f -> f.isFile && f.name.endsWith(".sql.gz") }
?.map { f ->
BackupFileInfo(
name = f.name,
sizeBytes = f.length(),
createdAt = Instant.ofEpochMilli(f.lastModified())
.atOffset(ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
)
}
?.sortedByDescending { it.createdAt }
?: emptyList()
} else {
emptyList()
}
call.respond(HttpStatusCode.OK, files)
}
} }

View file

@ -87,6 +87,13 @@
.paging-bar button { padding: 4px 10px; font-size: .85rem; } .paging-bar button { padding: 4px 10px; font-size: .85rem; }
.paging-bar span { font-size: .85rem; color: #A89070; } .paging-bar span { font-size: .85rem; color: #A89070; }
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #6B5B3E; border-top: 1px solid #4A3F2F; } footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #6B5B3E; border-top: 1px solid #4A3F2F; }
/* Tab navigation */
.tab-bar { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid #4A3F2F; }
.tab-btn { background: transparent; border: none; border-bottom: 3px solid transparent; color: #6B5B3E; padding: 12px 24px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .95rem; letter-spacing: .05em; transition: color .2s, border-color .2s; }
.tab-btn:hover { color: #E8D5B0; }
.tab-btn.active { color: #E8D5B0; border-bottom-color: #C1440E; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Scrollbar styling */ /* Scrollbar styling */
::-webkit-scrollbar { width: 10px; } ::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: #1A1815; } ::-webkit-scrollbar-track { background: #1A1815; }
@ -111,6 +118,16 @@
</section> </section>
<div id="admin-section" style="display:none"> <div id="admin-section" style="display:none">
<!-- Tab Navigation -->
<nav class="tab-bar">
<button class="tab-btn active" data-tab="tab-users" onclick="switchTab('tab-users')">User</button>
<button class="tab-btn" data-tab="tab-inventories" onclick="switchTab('tab-inventories')">Inventare</button>
<button class="tab-btn" data-tab="tab-backups" onclick="switchTab('tab-backups')">Backups</button>
</nav>
<!-- Tab: User -->
<div id="tab-users" class="tab-content active">
<!-- Stats Dashboard --> <!-- Stats Dashboard -->
<section class="card"> <section class="card">
<h2>Statistiken</h2> <h2>Statistiken</h2>
@ -166,6 +183,11 @@
</table> </table>
</section> </section>
</div><!-- /tab-users -->
<!-- Tab: Inventare -->
<div id="tab-inventories" class="tab-content">
<!-- Inventory Overview --> <!-- Inventory Overview -->
<section class="card"> <section class="card">
<h2>Inventar-Übersicht</h2> <h2>Inventar-Übersicht</h2>
@ -202,6 +224,23 @@
<button class="secondary" onclick="invPage++; renderInvTable()" id="inv-next">Weiter </button> <button class="secondary" onclick="invPage++; renderInvTable()" id="inv-next">Weiter </button>
</div> </div>
</section> </section>
</div><!-- /tab-inventories -->
<!-- Tab: Backups -->
<div id="tab-backups" class="tab-content">
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Backups</h2>
<button class="secondary" onclick="loadBackups()">↻ Aktualisieren</button>
</div>
<table id="backups-table">
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th></tr></thead>
<tbody id="backups-body"><tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr></tbody>
</table>
</section>
</div><!-- /tab-backups -->
</div> </div>
</main> </main>
@ -634,6 +673,56 @@
} }
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); }); document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
// ── Tab Navigation ───────────────────────────────────────────────────────
let backupsLoaded = false;
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId));
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.toggle('active', tc.id === tabId));
if (tabId === 'tab-backups' && !backupsLoaded) {
loadBackups();
}
}
// ── Backups ──────────────────────────────────────────────────────────────
async function loadBackups() {
const tbody = document.getElementById('backups-body');
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
try {
const res = await fetch('/api/admin/backups', { headers: { 'Authorization': 'Bearer ' + accessToken } });
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="3" class="error">Fehler beim Laden</td></tr>'; return; }
const backups = await res.json();
backupsLoaded = true;
if (!backups.length) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Keine Backups vorhanden</td></tr>';
return;
}
tbody.innerHTML = '';
backups.forEach(b => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = b.name;
const tdSize = document.createElement('td'); tdSize.textContent = formatBytes(b.sizeBytes);
const tdDate = document.createElement('td');
const d = new Date(b.createdAt);
tdDate.textContent = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
tr.append(tdName, tdSize, tdDate);
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3" class="error">Verbindungsfehler</td></tr>';
}
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return val.toFixed(i === 0 ? 0 : 1).replace('.', ',') + ' ' + units[i];
}
</script> </script>
<footer>Krisenvorrat Server v0.2 &middot; &copy; 2026 faenocasul</footer> <footer>Krisenvorrat Server v0.2 &middot; &copy; 2026 faenocasul</footer>
</body> </body>

View file

@ -12,9 +12,13 @@ import io.ktor.server.config.*
import io.ktor.server.testing.* import io.ktor.server.testing.*
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.File
class AdminTest { class AdminTest {
@ -150,4 +154,62 @@ class AdminTest {
} }
assertEquals(HttpStatusCode.OK, updateResponse.status) assertEquals(HttpStatusCode.OK, updateResponse.status)
} }
@Test
fun test_listBackups_asAdmin_returns200() = testApp {
val response = client.get("/api/admin/backups") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.OK, response.status)
val body = json.decodeFromString<JsonArray>(response.bodyAsText())
// Default /backups dir does not exist in tests → empty list
assertEquals(0, body.size)
}
@Test
fun test_listBackups_asNonAdmin_returns403() = testApp {
val response = client.get("/api/admin/backups") { bearerAuth(userToken) }
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun test_listBackups_noToken_returns401() = testApp {
val response = client.get("/api/admin/backups")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_listBackups_withFiles_returnsSortedList() {
val tempDir = File.createTempFile("backup-test", "").also { it.delete(); it.mkdirs() }
try {
// Create test backup files
val file1 = File(tempDir, "backup-2026-01-01.sql.gz").apply { writeText("dummy1") }
file1.setLastModified(1735689600000) // 2025-01-01
val file2 = File(tempDir, "backup-2026-05-15.sql.gz").apply { writeText("dummy2data") }
file2.setLastModified(1747267200000) // 2025-05-15
// Non-.sql.gz file should be ignored
File(tempDir, "readme.txt").apply { writeText("ignored") }
testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:admin_backup_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins(backupDir = tempDir)
}
val response = client.get("/api/admin/backups") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.OK, response.status)
val backups = json.decodeFromString<JsonArray>(response.bodyAsText())
assertEquals(2, backups.size)
// Sorted descending by date → file2 first
assertEquals("backup-2026-05-15.sql.gz", backups[0].jsonObject["name"]?.jsonPrimitive?.content)
assertEquals("backup-2026-01-01.sql.gz", backups[1].jsonObject["name"]?.jsonPrimitive?.content)
}
} finally {
tempDir.deleteRecursively()
}
}
} }