feat: Multi-User-Unterstützung mit JWT-Authentifizierung

- User-Modell (username, password_hash, role admin/user, is_active)
- Standard-Admin-Benutzer wird beim ersten Start automatisch angelegt
- JWT-Tokens (HS256) für Benutzer-Sessions, konfigurierbare Ablaufzeit
- API-Key bleibt für service-to-service-Calls (backward-compatible)
- POST /api/v1/auth/login → JWT-Token
- GET  /api/v1/auth/me   → aktueller Benutzer
- CRUD /api/v1/users/    → Benutzerverwaltung (nur Admin)
- TUI zeigt Login-Screen beim Start; nach Erfolg → MainScreen
- Passwort-Hashing mit bcrypt (python-jose für JWT)
This commit is contained in:
2026-03-04 20:55:13 +01:00
parent 0e2a8a6bc0
commit 6eb27a62b1
14 changed files with 421 additions and 10 deletions

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Input, Label, Static
class LoginScreen(Screen):
"""Anmeldebildschirm erscheint beim Start der TUI."""
BINDINGS = [Binding("ctrl+c", "quit_app", "Beenden")]
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Vertical(id="login-container"):
yield Static("Anmeldung", id="login-title")
yield Label("Benutzername:")
yield Input(placeholder="admin", id="username-input")
yield Label("Passwort:")
yield Input(placeholder="", password=True, id="password-input")
yield Static("", id="login-error")
yield Button("Anmelden", variant="primary", id="login-btn")
yield Footer()
def on_mount(self) -> None:
self.query_one("#username-input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "username-input":
self.query_one("#password-input", Input).focus()
elif event.input.id == "password-input":
self._do_login()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "login-btn":
self._do_login()
def _do_login(self) -> None:
import asyncio
asyncio.create_task(self._login_async())
async def _login_async(self) -> None:
username = self.query_one("#username-input", Input).value.strip()
password = self.query_one("#password-input", Input).value
if not username or not password:
self.query_one("#login-error", Static).update("Benutzername und Passwort eingeben.")
return
btn = self.query_one("#login-btn", Button)
btn.disabled = True
self.query_one("#login-error", Static).update("")
try:
ok = await self.app._api_client.login(username, password) # type: ignore[attr-defined]
except Exception:
ok = False
btn.disabled = False
if ok:
from tui.screens.main_screen import MainScreen
self.app.switch_screen(MainScreen())
else:
self.query_one("#login-error", Static).update("Ungültige Zugangsdaten oder Server nicht erreichbar.")
self.query_one("#password-input", Input).clear()
self.query_one("#password-input", Input).focus()
def action_quit_app(self) -> None:
self.app.exit()