morz-infoboard/docs/TENANT-FEATURE-PLAN.md
Jesko Anschütz 7e7a692521 Tenant-Feature Phase 1+2: Auth-Fundament + Login-Flow + UX-Textverbesserung
- DB-Migration 002_auth.sql (users + sessions Tabellen)
- AuthStore mit Session-Management, bcrypt, EnsureAdminUser
- Login/Logout Handler mit Cookie-Session (HttpOnly, SameSite=Lax)
- Login-Template (Bulma-Card, deutsche Labels)
- Config: AdminPassword, DefaultTenantSlug, DevMode
- Fallback-Texte: "Netzwerk offline" → "Server nicht erreichbar"
- TENANT-FEATURE-PLAN.md mit 46 Checkboxen als Steuerungsdatei

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:46:14 +01:00

307 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Tenant-/Firmen-Dashboard Implementierungs-Checkliste
Stand: 2026-03-23
Verantwortliche: Backy (Auth-Backend), Fred (Tenant-UI), Larry (Code-Review)
Diese Datei ist die einzige verbindliche Quelle fuer die Umsetzung des Tenant-Dashboards.
Alle Schritte sind als Checkboxen formuliert und enthalten Dateipfade und Funktionsnamen,
sodass kein weiterer Kontext benoetigt wird.
---
## Phase 1 Auth-Fundament
Ziel: Datenbank-Tabellen fuer User/Sessions anlegen, AuthStore mit allen CRUD-Methoden
implementieren, Config erweitern und Admin-User beim Start anlegen.
- [x] **DB-Migration anlegen** neue Datei
`server/backend/internal/db/migrations/002_auth.sql` erstellen mit zwei Tabellen:
`users` (id UUID PK, tenant_id UUID FK tenants.id, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'tenant', created_at TIMESTAMPTZ)
und `sessions` (id UUID PK DEFAULT gen_random_uuid(), user_id UUID FK users.id ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL); Index auf
`sessions(user_id)` und `sessions(expires_at)` anlegen.
- [x] **AuthStore-Datei anlegen** neue Datei
`server/backend/internal/store/auth.go` mit Typ `AuthStore struct{ pool *pgxpool.Pool }`
und Konstruktor `NewAuthStore(pool) *AuthStore`; Paket `store`, gleiche Import-Konventionen
wie `store.go`.
- [x] **AuthStore.GetUserByUsername implementieren** Methode
`(s *AuthStore) GetUserByUsername(ctx, username string) (*User, error)`;
neuer Domain-Typ `User` (id, tenant_id, username, password_hash, role, created_at) in
`store/auth.go` definieren; Query: `SELECT ... FROM users WHERE username=$1`.
- [x] **AuthStore Session-Methoden implementieren**
`CreateSession(ctx, userID string, ttl time.Duration) (*Session, error)`,
`GetSessionUser(ctx, sessionID string) (*User, error)` (JOIN users ON users.id = sessions.user_id
WHERE sessions.id=$1 AND expires_at > now()),
`DeleteSession(ctx, sessionID string) error`,
`CleanExpiredSessions(ctx) error` (DELETE FROM sessions WHERE expires_at <= now()).
- [x] **AuthStore.EnsureAdminUser implementieren** Methode
`(s *AuthStore) EnsureAdminUser(ctx, tenantSlug, password string) error`;
prueft ob `username='admin'` existiert, legt ihn mit `bcrypt.GenerateFromPassword` an falls nicht;
haengt den User an den Tenant mit `tenantSlug` (via Subquery auf `tenants.slug`).
- [x] **Config erweitern** in `server/backend/internal/config/config.go` drei neue Felder
ergaenzen: `AdminPassword string` (Env: `MORZ_INFOBOARD_ADMIN_PASSWORD`, kein Fallback
leer bedeutet: kein EnsureAdminUser-Lauf),
`DefaultTenantSlug string` (Env: `MORZ_INFOBOARD_DEFAULT_TENANT`, Fallback `"morz"`),
`DevMode bool` (Env: `MORZ_INFOBOARD_DEV_MODE`, Fallback `false`; wenn true: Session-Cookie
wird auch ohne HTTPS gesetzt).
- [x] **main.go / app.go: AuthStore verdrahten** in `server/backend/internal/app/app.go`
`store.NewAuthStore(pool.Pool)` instanziieren und an `httpapi.RouterDeps` uebergeben
(neues Feld `AuthStore *store.AuthStore`).
- [x] **EnsureAdminUser beim Start aufrufen** in `app.New()` nach der Store-Initialisierung:
wenn `cfg.AdminPassword != ""`, dann `authStore.EnsureAdminUser(ctx, cfg.DefaultTenantSlug,
cfg.AdminPassword)` aufrufen; Fehler loggen, aber nicht fatal (Server soll starten).
- [x] **Migrations-Runner pruefen** sicherstellen, dass `db.Connect` in
`server/backend/internal/db/db.go` alle Dateien aus `migrations/` in alphabetischer
Reihenfolge ausfuehrt und `002_auth.sql` damit automatisch eingespielt wird; ggf. Logzeile
hinzufuegen.
- [x] **Doku** Abschnitt "Auth-Datenbankschema" in `docs/SCHEMA.md` ergaenzen:
Tabellen `users` und `sessions` mit allen Spalten und Constraints beschreiben.
---
## Phase 2 Login-Flow
Ziel: Login-Seite rendern, POST-Handler mit bcrypt-Pruefung, Session-Cookie setzen,
Logout implementieren, alle Routen eintragen.
- [x] **Login-Template definieren** in `server/backend/internal/httpapi/manage/templates.go`
neue Template-Konstante `loginTmpl` anlegen; Bulma-Card zentriert auf der Seite
(`columns is-centered`, `column is-narrow`), Formular mit Feldern `username` und `password`
(type=password), Submit-Button `Anmelden`, Flash-Message-Bereich fuer Fehlermeldungen
(gleiche Klasse wie bestehende Flash-Messages in `manageTmpl`).
- [x] **HandleLoginUI implementieren** in `server/backend/internal/httpapi/manage/auth.go`
(neue Datei) Funktion `HandleLoginUI() http.HandlerFunc`; GET-Handler, rendert `loginTmpl`;
wenn Session-Cookie bereits gueltig ist, redirect zu `/tenant/{slug}/dashboard`.
- [x] **HandleLoginPost implementieren** in `auth.go` Funktion
`HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc`;
liest `username`/`password` aus Formular, ruft `authStore.GetUserByUsername` auf,
prueft Passwort mit `bcrypt.CompareHashAndPassword`, erstellt Session mit
`authStore.CreateSession(..., 24*time.Hour)`, setzt `HttpOnly`-Cookie `morz_session`
(Secure=true ausser bei DevMode), redirectet je nach Rolle:
`admin` zu `/admin`, `tenant` zu `/tenant/{tenantSlug}/dashboard`.
- [x] **HandleLogoutPost implementieren** in `auth.go` Funktion
`HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc`;
liest Cookie `morz_session`, ruft `authStore.DeleteSession` auf,
setzt Cookie mit MaxAge=-1 (loeschen), redirectet zu `/login`.
- [x] **Routen eintragen** in `server/backend/internal/httpapi/router.go` in
`registerManageRoutes` hinzufuegen:
`mux.HandleFunc("GET /login", auth.HandleLoginUI())`,
`mux.HandleFunc("POST /login", auth.HandleLoginPost(d.AuthStore, cfg))`,
`mux.HandleFunc("POST /logout", auth.HandleLogoutPost(d.AuthStore))`;
Import `httpapi/manage/auth` ergaenzen (oder Package zusammenfassen Entscheidung vor Umsetzung
treffen).
- [x] **Manuell testen** Server starten, `GET /login` aufrufen, Login mit
Admin-Credentials pruefen, Cookie in Devtools sichtbar, Logout funktioniert,
Fehler bei falschem Passwort zeigt Flash-Message.
- [x] **Doku** `docs/SERVER-KONZEPT.md` Abschnitt "Authentifizierung" um Login-Flow
und Cookie-Lebensdauer erganzen.
---
## Phase 3 Middleware + Tenant-Isolation
Ziel: Drei Middleware-Funktionen implementieren, Router umbauen sodass geschuetzte Routen
hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entfernen.
- [ ] **RequireAuth implementieren** in `server/backend/internal/httpapi/middleware.go`
(neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`;
liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf,
speichert `*store.User` im Context (eigener Key-Typ `contextKey`),
redirectet bei Fehler zu `/login?next=<aktueller-Pfad>`.
- [ ] **RequireAdmin implementieren** in `middleware.go` Funktion
`RequireAdmin(next http.Handler) http.Handler`; liest User aus Context,
prueft `user.Role == "admin"`, antwortet sonst mit 403.
- [ ] **RequireTenantAccess implementieren** in `middleware.go` Funktion
`RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus
Request-Path, erlaubt Zugriff wenn `user.Role == "admin"` oder `user.TenantSlug == tenantSlug`
(dazu Feld `TenantSlug string` auf `store.User` erganzen, per JOIN in `GetSessionUser` befullen),
antwortet sonst mit 403.
- [ ] **Router umbauen** in `router.go` die bisherige flache Route-Liste in Gruppen
umstrukturieren: `/admin`-Routen hinter `RequireAuth` + `RequireAdmin` legen,
`/manage/{screenSlug}`-Routen und kuenftige `/tenant/{tenantSlug}/...`-Routen hinter
`RequireAuth` + `RequireTenantAccess` legen; Hilfsfunktion `chain(...Middleware)` nutzen
oder inline wrappen.
- [ ] **Hardcoded "morz" entfernen (Stelle 1)** in
`server/backend/internal/httpapi/manage/ui.go` Zeile 93:
`tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus
Context; `tenant_id` aus `user.TenantID` verwenden.
- [ ] **Hardcoded "morz" entfernen (Stelle 2)** in `ui.go` Zeile 154:
gleiche Ersetzung fuer `HandleManageUI`.
- [ ] **Hardcoded "morz" entfernen (Stelle 3)** in `ui.go` Zeile 197:
gleiche Ersetzung fuer `HandleProvisionUI`; SSH-User `"morz"` (Zeile 191) aus Config
lesen oder als optionalen Query-Parameter ermoeglichen.
- [ ] **Hardcoded "morz" entfernen (Stelle 4)** in
`server/backend/internal/httpapi/manage/register.go` Zeile 43:
`tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` um Abschnitt "Middleware-Kette" erganzen:
Schaubild der Route-Gruppen mit den jeweiligen Middlewares.
---
## Phase 4 Tenant-Dashboard UI
Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht
(Screens mit Live-Status, Mediathek mit Upload), Navbar, Routing.
- [ ] **Package-Verzeichnis anlegen** neues Verzeichnis
`server/backend/internal/httpapi/tenant/`; Dateien:
`tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`.
- [ ] **tenantDashTmpl definieren** in `tenant/templates.go` Bulma-Layout mit:
Navbar (Logo links, "Abmelden"-Button rechts als POST /logout),
zwei Tabs (`<div class="tabs">`) mit IDs `tab-screens` und `tab-media`,
Tab A "Meine Monitore", Tab B "Mediathek"; JS-Snippet fuer Tab-Switching inline am Ende
des Templates (analog zu bestehenden inline-Scripts in `manage/templates.go`).
- [ ] **Tab A Screen-Karten implementieren** in `tenantDashTmpl` Tab A mit Bulma-Cards
pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge
(Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`;
JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen
(is-success / is-danger / is-warning).
- [ ] **Tab B Mediathek mit Upload implementieren** in `tenantDashTmpl` Tab B:
Upload-Formular (multipart, POST `/tenant/{tenantSlug}/upload`), Dateiliste als Bulma-Table
(Titel, Typ, Groesse, Datum, Loeschen-Button mit Modal-Confirmation analog zu `manage/templates.go`);
Upload-Fortschrittsbalken (bestehende JS-Logik aus `manageTmpl` wiederverwenden oder extrahieren).
- [ ] **HandleTenantDashboard implementieren** in `tenant/tenant.go` Funktion
`HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore,
mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`;
liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`.
- [ ] **HandleTenantUpload implementieren** in `tenant/tenant.go` Funktion
`HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore,
uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`,
aber ohne Screen-Kontext (Media gehoert direkt dem Tenant);
nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`.
- [ ] **Navbar in Admin-UI erganzen** in `manage/templates.go` in `adminTmpl` und
`manageTmpl` eine minimale Bulma-Navbar mit "Admin" (aktiv) und "Abmelden"-Button erganzen,
sodass beide UIs optisch konsistent sind.
- [ ] **Responsive pruefen** `tenantDashTmpl` auf `is-mobile`-Breakpoint testen:
Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen
Screens nutzbar bleiben.
- [ ] **Routen eintragen** in `router.go` innerhalb `registerManageRoutes` hinter
`RequireAuth` + `RequireTenantAccess`:
`mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`,
`mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` neuen Abschnitt "Tenant-Dashboard" mit
URL-Schema, Tab-Beschreibung und Status-Polling-Intervall erganzen.
---
## Phase 5 Manage-UI Anpassung
Ziel: Der "Zurueck"-Link in der Manage-UI soll kontextsensitiv sein
aus dem Admin-Bereich kommend zeigt er zur Admin-Uebersicht,
aus dem Tenant-Dashboard kommend zurueck zum Dashboard.
- [ ] **TemplateData um BackLink/BackLabel erweitern** in `manage/ui.go`
Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder
`BackLink string` und `BackLabel string` erganzen.
- [ ] **HandleManageUI: BackLink aus Query-Parameter lesen** in `HandleManageUI`:
wenn `r.URL.Query().Get("from") == "tenant"`, dann
`BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`;
sonst `BackLink = "/admin"` und `BackLabel = "← Admin"`.
- [ ] **manageTmpl: statisches "← Admin" ersetzen** in `manage/templates.go`
den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen.
- [ ] **Tenant-Dashboard: Links zu Manage-UI mit ?from=tenant** in `tenant/templates.go`
jeden "Playlist bearbeiten"-Link als `/manage/{screenSlug}?from=tenant` formulieren,
damit der Ruecklink korrekt gesetzt wird.
- [ ] **Breadcrumb-Navigation** optional, aber empfohlen: in `manageTmpl` oberhalb des
Hauptinhalts eine Bulma-Breadcrumb-Leiste einfuegen:
Admin-Pfad: `Admin > {ScreenName}`, Tenant-Pfad: `Dashboard > {ScreenName}`;
Daten aus `BackLabel`/`BackLink` und `Screen.Name` zusammensetzen.
- [ ] **Doku** Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert
den `?from=tenant`-Parameter und das BackLink-Verhalten.
---
## Phase 6 Abschluss + Deployment
Ziel: Session-Cleanup als Hintergrundprozess, Secrets in Docker/Ansible,
Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumentation.
- [ ] **Session-Cleanup-Ticker implementieren** in `app.go` nach Server-Start einen
`time.NewTicker(1 * time.Hour)` starten (als Goroutine), der `authStore.CleanExpiredSessions`
aufruft; Ticker beim Shutdown stoppen (Context-Abbruch oder `defer ticker.Stop()`).
- [ ] **Docker-Secret fuer AdminPassword** in `compose/` (docker-compose.yml oder
ein neues Override-File) das Env `MORZ_INFOBOARD_ADMIN_PASSWORD` aus einem Docker-Secret
oder `.env`-Datei beziehen; `.env.example` mit leerem Wert als Dokumentation erganzen.
- [ ] **Ansible-Variable erganzen** in `ansible/` (Rollen `signage_server` oder
`signage_base`) Variable `morz_admin_password` als Vault-Variable definieren;
in das entsprechende Docker-Compose-Template eintragen.
- [ ] **Code-Review durch Larry** Pull-Request / Branch auf Gitea erstellen,
Larry als Reviewer eintragen; Review-Fokus: SQL-Injection, Session-Fixation,
bcrypt-Cost-Faktor (mind. 12), Middleware-Reihenfolge, fehlende Fehlerbehandlung.
- [ ] **End-to-End-Test** manuelle Checkliste durchlaufen:
(1) Admin-Login, Admin-UI sichtbar, Logout funktioniert;
(2) Tenant-Login, nur eigene Screens und Medien sichtbar;
(3) Tenant kann nicht auf `/admin` zugreifen (403);
(4) Upload im Tenant-Dashboard, Datei erscheint in Liste;
(5) Playlist-Bearbeitung aus Tenant-Dashboard, Ruecklink zeigt "← Dashboard".
- [ ] **Deployment** neues Image bauen (`make build` oder CI-Pipeline),
`docker compose pull && docker compose up -d` auf dem Server ausfuehren,
Migration 002_auth.sql wird automatisch eingespielt, Logs auf Fehler pruefen.
- [ ] **TODO.md nachziehen** abgearbeitete Punkte in `TODO.md` abhaken:
"Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen" (Phase 4),
"Authentifizierungskonzept festlegen" (falls noch offen),
"Mandantentrennung in den APIs absichern" (falls noch offen).
- [ ] **README / DEVELOPMENT nachziehen** `DEVELOPMENT.md` um Abschnitt
"Lokale Entwicklung mit Login" erganzen: Env-Variable `MORZ_INFOBOARD_ADMIN_PASSWORD=dev`
und `MORZ_INFOBOARD_DEV_MODE=true` setzen, um ohne HTTPS-Cookie arbeiten zu koennen.
---
## Offene Entscheidungen
Diese Punkte sind bewusst noch nicht spezifiziert. Sie muessen vor oder waehrend der
Umsetzung entschieden und dann in dieser Datei oder `docs/OFFENE-ARCHITEKTURFRAGEN.md`
festgeschrieben werden.
| Thema | Frage | Wer entscheidet |
|---|---|---|
| Passwort-Reset | Soll es eine "Passwort vergessen"-Funktion geben? Falls ja: E-Mail-Versand oder Admin-seitig? | Jesko |
| CSRF-Schutz | Brauchen die POST-Formulare CSRF-Tokens? Aktuell kein Schutz implementiert. Empfehlung: `gorilla/csrf` oder eigenes Double-Submit-Cookie. | Backy + Larry |
| Rate-Limiting | Soll `/login` gegen Brute-Force abgesichert werden (z.B. 10 Versuche / 15 Min per IP)? | Backy |
| Session-TTL | 24 Stunden ist ein erster Wert. Soll es eine "Eingeloggt bleiben"-Option geben? | Jesko |
| Tenant-User-Verwaltung | Wer legt Tenant-User an? Aktuell nur der Admin via DB oder EnsureAdminUser. Braucht es eine UI fuer neue Firmen-User? | Jesko |