diff --git a/docs/superpowers/plans/2026-03-27-restricted-rolle.md b/docs/superpowers/plans/2026-03-27-restricted-rolle.md new file mode 100644 index 0000000..0a2e9e7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-restricted-rolle.md @@ -0,0 +1,672 @@ +# Restricted-Rolle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Neue Rolle `"restricted"` einführen — Nutzer dürfen Medien hochladen und Playlist bearbeiten, aber keine Anzeige-Steuerung (An/Aus, Zeitplan, Override) vornehmen. + +**Architecture:** Neuer Rollenwert im bestehenden `users.role`-String-Feld. Neue Middleware `RequireNotRestricted` blockt restricted-User mit 403 auf Steuerungs-Endpunkten. Templates erhalten `UserRole string` und blenden Steuerungs-UI per `{{if ne .UserRole "restricted"}}` aus. + +**Tech Stack:** Go 1.25, `net/http`, `html/template`, pgx v5, Bulma CSS + +--- + +## Geänderte Dateien + +| Datei | Änderung | +|-------|----------| +| `server/backend/internal/httpapi/middleware.go` | `RequireNotRestricted` hinzufügen | +| `server/backend/internal/httpapi/middleware_test.go` | NEU — Tests für `RequireNotRestricted` | +| `server/backend/internal/store/auth.go` | `CreateScreenUser` bekommt `role`-Parameter; `ListScreenUsers` schließt restricted ein | +| `server/backend/internal/httpapi/manage/ui.go` | `HandleCreateScreenUser` liest `role` aus Form; beide Handler übergeben `UserRole` ans Template | +| `server/backend/internal/httpapi/router.go` | Neue Middleware-Kombinatoren `authScreenControl` + `authOnlyControl`; Steuerungs-Routen umverdrahten | +| `server/backend/internal/httpapi/manage/templates.go` | Admin-Formular: Rolle-Dropdown; User-Liste: Rollen-Badge; Übersicht + Detailseite: UI-Guards | + +--- + +## Task 1: `RequireNotRestricted` Middleware + Tests + +**Files:** +- Modify: `server/backend/internal/httpapi/middleware.go` +- Create: `server/backend/internal/httpapi/middleware_test.go` + +- [ ] **Step 1: Failing-Test schreiben** + +Erstelle `server/backend/internal/httpapi/middleware_test.go`: + +```go +package httpapi_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi" + "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +func userCtx(role string) context.Context { + return reqcontext.WithUser(context.Background(), &store.User{Role: role}) +} + +func TestRequireNotRestricted_blocks_restricted(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(userCtx("restricted")) + rr := httptest.NewRecorder() + httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not be called") + })).ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rr.Code) + } +} + +func TestRequireNotRestricted_allows_screen_user(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(userCtx("screen_user")) + rr := httptest.NewRecorder() + called := false + httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })).ServeHTTP(rr, req) + if !called { + t.Fatal("expected next to be called") + } +} + +func TestRequireNotRestricted_allows_admin(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(userCtx("admin")) + rr := httptest.NewRecorder() + called := false + httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })).ServeHTTP(rr, req) + if !called { + t.Fatal("expected next to be called") + } +} + +func TestRequireNotRestricted_allows_no_user(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + rr := httptest.NewRecorder() + called := false + httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })).ServeHTTP(rr, req) + if !called { + t.Fatal("no user in context — RequireAuth handles that, this middleware passes through") + } +} +``` + +- [ ] **Step 2: Test laufen lassen — muss fehlschlagen** + +```bash +cd server/backend && go test ./internal/httpapi/ -run TestRequireNotRestricted -v +``` + +Erwartetes Ergebnis: Compile-Fehler `undefined: httpapi.RequireNotRestricted` + +- [ ] **Step 3: Implementation in `middleware.go` hinzufügen** + +In `server/backend/internal/httpapi/middleware.go` direkt nach `RequireAdmin` (nach Zeile 56) einfügen: + +```go +// RequireNotRestricted is middleware that blocks users with role "restricted". +// It must be chained after RequireAuth (so a user is present in context). +// On failure it responds with 403 Forbidden. +func RequireNotRestricted(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user != nil && user.Role == "restricted" { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} +``` + +- [ ] **Step 4: Tests laufen lassen — müssen bestehen** + +```bash +cd server/backend && go test ./internal/httpapi/ -run TestRequireNotRestricted -v +``` + +Erwartetes Ergebnis: 4 Tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/backend/internal/httpapi/middleware.go \ + server/backend/internal/httpapi/middleware_test.go +git commit -m "feat(auth): RequireNotRestricted middleware" +``` + +--- + +## Task 2: Store — `CreateScreenUser` role-Parameter + `ListScreenUsers` Fix + +**Files:** +- Modify: `server/backend/internal/store/auth.go` + +- [ ] **Step 1: Failing-Test schreiben** + +Es gibt noch keine Unit-Tests für den Store (Integration-Tests laufen gegen echte DB, die im CI nicht verfügbar ist). Stattdessen sichern wir die Signatur über den Compiler ab — wir schreiben zunächst den Test in einem separaten Compile-Check-Paket NICHT, sondern prüfen direkt via `go build`. + +Ersetze `CreateScreenUser` in `server/backend/internal/store/auth.go`: + +Aktuell (Zeile 162–187): +```go +func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password string) (*User, error) { + ... + row := s.pool.QueryRow(ctx, + `insert into users(tenant_id, username, password_hash, role) + values($1, $2, $3, 'screen_user') + returning id, tenant_id, $4::text, username, password_hash, role, created_at`, + tenantID, username, string(hash), tenantSlug) +``` + +Ersetze durch: + +```go +// CreateScreenUser creates a new user with the given role for the tenant +// identified by tenantSlug. role must be "screen_user" or "restricted". +// The password is hashed with bcrypt (cost 12). +// Returns pgx.ErrNoRows if the tenant does not exist, or a wrapped error if +// the username is already taken (unique constraint violation). +func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password, role string) (*User, error) { + if role != "screen_user" && role != "restricted" { + return nil, fmt.Errorf("auth: invalid role: %s", role) + } + var tenantID string + err := s.pool.QueryRow(ctx, `select id from tenants where slug = $1`, tenantSlug).Scan(&tenantID) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("auth: tenant not found: %s", tenantSlug) + } + return nil, fmt.Errorf("auth: resolve tenant: %w", err) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return nil, fmt.Errorf("auth: hash password: %w", err) + } + + row := s.pool.QueryRow(ctx, + `insert into users(tenant_id, username, password_hash, role) + values($1, $2, $3, $4) + returning id, tenant_id, $5::text, username, password_hash, role, created_at`, + tenantID, username, string(hash), role, tenantSlug) + u, err := scanUserWithSlug(row) + if err != nil { + return nil, fmt.Errorf("auth: create screen user: %w", err) + } + return u, nil +} +``` + +- [ ] **Step 2: `ListScreenUsers` erweitern — restricted-User einschließen** + +Aktuell (Zeile 191–210) filtert `where t.slug = $1 and u.role = 'screen_user'`. Ändere auf: + +```go +func (s *AuthStore) ListScreenUsers(ctx context.Context, tenantSlug string) ([]*User, error) { + rows, err := s.pool.Query(ctx, + `select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at + from users u + left join tenants t on t.id = u.tenant_id + where t.slug = $1 and u.role IN ('screen_user', 'restricted') + order by u.username`, tenantSlug) + if err != nil { + return nil, fmt.Errorf("auth: list screen users: %w", err) + } + defer rows.Close() + var out []*User + for rows.Next() { + u, err := scanUserWithSlug(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} +``` + +- [ ] **Step 3: Kompilierung prüfen** + +```bash +cd server/backend && go build ./... +``` + +Erwartetes Ergebnis: Compiler-Fehler in `manage/ui.go` — `CreateScreenUser` bekommt zu wenig Argumente. Das beheben wir in Task 4. + +- [ ] **Step 4: Commit (noch nicht buildbar — wird in Task 4 fixiert)** + +```bash +git add server/backend/internal/store/auth.go +git commit -m "feat(store): CreateScreenUser nimmt role-Parameter; ListScreenUsers schließt restricted ein" +``` + +--- + +## Task 3: Router — Steuerungs-Routen mit `RequireNotRestricted` absichern + +**Files:** +- Modify: `server/backend/internal/httpapi/router.go` + +- [ ] **Step 1: Neue Middleware-Kombinatoren in `registerManageRoutes` einfügen** + +In `router.go`, nach dem Block `authScreen := func(h http.Handler) http.Handler { ... }` (nach Zeile 143), einfügen: + +```go +// authScreenControl: wie authScreen, aber zusätzlich restricted-User blockiert. +// Für Endpunkte, die restricted-User nicht nutzen dürfen (Display, Schedule, Override). +authScreenControl := func(h http.Handler) http.Handler { + return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), RequireNotRestricted, setCSRF, csrf) +} +// authOnlyControl: wie authOnly, aber zusätzlich restricted-User blockiert. +// Für globalen Override (kein spezifischer Screen). +authOnlyControl := func(h http.Handler) http.Handler { + return chain(h, RequireAuth(d.AuthStore), RequireNotRestricted, setCSRF, csrf) +} +``` + +- [ ] **Step 2: Steuerungs-Routen auf neue Kombinatoren umstellen** + +Ändere folgende Routen (in `router.go`): + +```go +// ── Display control ─────────────────────────────────────────────────────────── +mux.Handle("POST /api/v1/screens/{screenSlug}/display", + authScreenControl(http.HandlerFunc(manage.HandleDisplayCommand(notifier)))) + +// ── Schedule control ────────────────────────────────────────────────────────── +mux.Handle("POST /api/v1/screens/{screenSlug}/schedule", + authScreenControl(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore)))) + +// ── Globaler Override ──────────────────────────────────────────────────────── +mux.Handle("GET /api/v1/global-override", + authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore)))) +mux.Handle("POST /api/v1/global-override", + authOnlyControl(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier)))) +mux.Handle("DELETE /api/v1/global-override", + authOnlyControl(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore)))) + +// ── Per-Screen Override ─────────────────────────────────────────────────────── +mux.Handle("POST /api/v1/screens/{screenSlug}/override", + authScreenControl(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore)))) +``` + +- [ ] **Step 3: Kompilierung prüfen** + +```bash +cd server/backend && go build ./... +``` + +Erwartetes Ergebnis: Fehler bleibt in `manage/ui.go` (Task 4 nicht fertig). Keine neuen Fehler. + +- [ ] **Step 4: Commit** + +```bash +git add server/backend/internal/httpapi/router.go +git commit -m "feat(router): Steuerungs-Endpunkte blocken restricted-User (403)" +``` + +--- + +## Task 4: Handler — `HandleCreateScreenUser` + `UserRole` ans Template übergeben + +**Files:** +- Modify: `server/backend/internal/httpapi/manage/ui.go` + +- [ ] **Step 1: `HandleCreateScreenUser` — `role` aus Formular lesen** + +Ersetze den Inhalt von `HandleCreateScreenUser` (Zeile 204–232): + +```go +// HandleCreateScreenUser creates a new screen user (role: screen_user or restricted) for the default tenant. +func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + if username == "" || password == "" { + http.Redirect(w, r, "/admin?tab=users&msg=error_empty", http.StatusSeeOther) + return + } + + role := r.FormValue("role") + if role != "screen_user" && role != "restricted" { + role = "screen_user" + } + + tenantSlug := "morz" + if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { + tenantSlug = u.TenantSlug + } + + _, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password, role) + if err != nil { + slog.Error("create screen user failed", "event", "create_screen_user_failed", + "tenant_slug", tenantSlug, "username", username, "err", err) + http.Redirect(w, r, "/admin?tab=users&msg=error_exists", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin?tab=users&msg=user_added", http.StatusSeeOther) + } +} +``` + +- [ ] **Step 2: `HandleScreenOverview` — `UserRole` ans Template übergeben** + +In `HandleScreenOverview` den `renderTemplate`-Aufruf (ab Zeile 336) erweitern: + +```go +renderTemplate(w, t, map[string]any{ + "Cards": cards, + "CSRFToken": csrfToken, + "GlobalOverride": activeOverride, + "UserRole": u.Role, +}) +``` + +(`u` ist bereits am Anfang des Handlers mit `u := reqcontext.UserFromContext(r.Context())` deklariert.) + +- [ ] **Step 3: `HandleManageUI` — `UserRole` ans Template übergeben** + +In `HandleManageUI`, vor dem `renderTemplate`-Aufruf (Zeile 453), `userRole` bestimmen: + +```go +userRole := "" +if u := reqcontext.UserFromContext(r.Context()); u != nil { + userRole = u.Role +} +``` + +Dann im `renderTemplate`-Aufruf ergänzen: + +```go +renderTemplate(w, t, map[string]any{ + "Screen": screen, + "Tenant": tenant, + "Playlist": playlist, + "Items": items, + "Assets": assets, + "AddedAssets": addedAssets, + "BackLink": backLink, + "BackLabel": backLabel, + "IsAdmin": isAdmin, + "AccessibleScreens": accessibleScreens, + "ServerTimezone": serverTimezone, + "CSRFToken": csrfToken, + "DisplayState": displayState, + "Schedule": schedule, + "UserRole": userRole, +}) +``` + +- [ ] **Step 4: Kompilierung prüfen** + +```bash +cd server/backend && go build ./... +``` + +Erwartetes Ergebnis: `go build ./...` — PASS, kein Fehler + +- [ ] **Step 5: Tests laufen lassen** + +```bash +cd server/backend && go test ./... +``` + +Erwartetes Ergebnis: alle Tests PASS (inkl. `TestRequireNotRestricted_*`) + +- [ ] **Step 6: Commit** + +```bash +git add server/backend/internal/httpapi/manage/ui.go +git commit -m "feat(handler): HandleCreateScreenUser liest role; UserRole ans Template übergeben" +``` + +--- + +## Task 5: Template — Admin-Formular: Rolle-Dropdown + Rollen-Badge in User-Liste + +**Files:** +- Modify: `server/backend/internal/httpapi/manage/templates.go` + +- [ ] **Step 1: Rolle-Dropdown ins User-Anlegen-Formular einfügen** + +Im Admin-Template (`adminTmpl`), das User-Anlegen-Formular (um Zeile 521–548). Aktuell hat es 3 Spalten (`is-4` je Spalte). Füge eine vierte Spalte für die Rolle ein und passe alle auf `is-3` an: + +Ersetze den `
Mind. 8 Zeichen
+