# 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. - [x] **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=`. - [x] **RequireAdmin implementieren** – in `middleware.go` Funktion `RequireAdmin(next http.Handler) http.Handler`; liest User aus Context, prueft `user.Role == "admin"`, antwortet sonst mit 403. - [x] **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. - [x] **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. - [x] **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. - [x] **Hardcoded "morz" entfernen (Stelle 2)** – in `ui.go` Zeile 154: gleiche Ersetzung fuer `HandleManageUI`. - [x] **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. - [x] **Hardcoded "morz" entfernen (Stelle 4)** – in `server/backend/internal/httpapi/manage/register.go` Zeile 43: `tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen. - [x] **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. - [x] **Package-Verzeichnis anlegen** – neues Verzeichnis `server/backend/internal/httpapi/tenant/`; Dateien: `tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`. - [x] **tenantDashTmpl definieren** – in `tenant/templates.go` Bulma-Layout mit: Navbar (Logo links, "Abmelden"-Button rechts als POST /logout), zwei 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`). - [x] **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). - [x] **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). - [x] **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`. - [x] **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`. - [x] **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. - [x] **Responsive pruefen** – `tenantDashTmpl` auf `is-mobile`-Breakpoint testen: Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen Screens nutzbar bleiben. - [x] **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(...))`. - [x] **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. - [x] **TemplateData um BackLink/BackLabel erweitern** – in `manage/ui.go` Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder `BackLink string` und `BackLabel string` erganzen. - [x] **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"`. - [x] **manageTmpl: statisches "← Admin" ersetzen** – in `manage/templates.go` den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen. - [x] **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. - [x] **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. - [x] **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. - [x] **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. - [x] **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). - [x] **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 |