Compare commits
No commits in common. "787287b32899340e8b733e0f319c7e22933b26d1" and "4996ff6defde3d0fb6216a2c542195a7e2636c63" have entirely different histories.
787287b328
...
4996ff6def
10 changed files with 21 additions and 931 deletions
|
|
@ -535,7 +535,6 @@ Beide Endpunkte erfordern `RequireAuth` + `RequireScreenAccess` (`authScreen`-Mi
|
||||||
Sendet einen MQTT-Befehl zum Ein- oder Ausschalten des physischen Displays.
|
Sendet einen MQTT-Befehl zum Ein- oder Ausschalten des physischen Displays.
|
||||||
|
|
||||||
**Auth:** Erforderlich (Bearer-Token oder Session-Cookie). Screen-Zugriff erforderlich.
|
**Auth:** Erforderlich (Bearer-Token oder Session-Cookie). Screen-Zugriff erforderlich.
|
||||||
**Rollen:** Nur `admin_user` und `screen_user`. Benutzer mit Rolle `restricted` erhalten `403 Forbidden`.
|
|
||||||
|
|
||||||
**Path-Parameter:**
|
**Path-Parameter:**
|
||||||
- `screenSlug` — Slug des Screens
|
- `screenSlug` — Slug des Screens
|
||||||
|
|
@ -553,7 +552,6 @@ oder
|
||||||
|
|
||||||
**Fehler:**
|
**Fehler:**
|
||||||
- `400 Bad Request` — `state` ist nicht `"on"` oder `"off"`, oder ungültiges JSON
|
- `400 Bad Request` — `state` ist nicht `"on"` oder `"off"`, oder ungültiges JSON
|
||||||
- `403 Forbidden` — Benutzer hat nicht die erforderliche Rolle (restricted-Benutzer dürfen Display nicht steuern)
|
|
||||||
- `502 Bad Gateway` — MQTT-Publish fehlgeschlagen
|
- `502 Bad Gateway` — MQTT-Publish fehlgeschlagen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -563,7 +561,6 @@ oder
|
||||||
Speichert den Zeitplan für das automatische Ein-/Ausschalten eines Displays.
|
Speichert den Zeitplan für das automatische Ein-/Ausschalten eines Displays.
|
||||||
|
|
||||||
**Auth:** Erforderlich. Screen-Zugriff erforderlich.
|
**Auth:** Erforderlich. Screen-Zugriff erforderlich.
|
||||||
**Rollen:** Nur `admin_user` und `screen_user`. Benutzer mit Rolle `restricted` erhalten `403 Forbidden`.
|
|
||||||
|
|
||||||
**Path-Parameter:**
|
**Path-Parameter:**
|
||||||
- `screenSlug` — Slug des Screens
|
- `screenSlug` — Slug des Screens
|
||||||
|
|
@ -585,7 +582,6 @@ Der Scheduler prüft jede Minute, ob die aktuelle Uhrzeit mit `power_on_time` od
|
||||||
|
|
||||||
**Fehler:**
|
**Fehler:**
|
||||||
- `400 Bad Request` — Zeitformat ungültig (nicht `HH:MM`), oder ungültiges JSON
|
- `400 Bad Request` — Zeitformat ungültig (nicht `HH:MM`), oder ungültiges JSON
|
||||||
- `403 Forbidden` — Benutzer hat nicht die erforderliche Rolle (restricted-Benutzer dürfen Zeitplan nicht ändern)
|
|
||||||
- `404 Not Found` — Screen nicht vorhanden
|
- `404 Not Found` — Screen nicht vorhanden
|
||||||
- `500 Internal Server Error` — DB-Fehler
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
|
|
@ -616,8 +612,6 @@ Ruft den aktuell aktiven globalen Override ab.
|
||||||
|
|
||||||
Setzt einen globalen Override und sendet sofort MQTT-Befehle an alle Screens.
|
Setzt einen globalen Override und sendet sofort MQTT-Befehle an alle Screens.
|
||||||
|
|
||||||
**Rollen:** Nur `admin_user` und `screen_user`. Benutzer mit Rolle `restricted` erhalten `403 Forbidden`.
|
|
||||||
|
|
||||||
**Request-Body:**
|
**Request-Body:**
|
||||||
```json
|
```json
|
||||||
{"type":"off","until":"2026-04-05T18:00:00+02:00"}
|
{"type":"off","until":"2026-04-05T18:00:00+02:00"}
|
||||||
|
|
@ -630,7 +624,6 @@ Setzt einen globalen Override und sendet sofort MQTT-Befehle an alle Screens.
|
||||||
|
|
||||||
**Fehler:**
|
**Fehler:**
|
||||||
- `400 Bad Request` — `type` nicht "on"/"off", oder ungültiges Zeitformat
|
- `400 Bad Request` — `type` nicht "on"/"off", oder ungültiges Zeitformat
|
||||||
- `403 Forbidden` — Benutzer hat nicht die erforderliche Rolle (restricted-Benutzer dürfen Override nicht setzen)
|
|
||||||
- `500 Internal Server Error` — DB-Fehler
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -639,12 +632,9 @@ Setzt einen globalen Override und sendet sofort MQTT-Befehle an alle Screens.
|
||||||
|
|
||||||
Hebt den aktuellen globalen Override auf.
|
Hebt den aktuellen globalen Override auf.
|
||||||
|
|
||||||
**Rollen:** Nur `admin_user` und `screen_user`. Benutzer mit Rolle `restricted` erhalten `403 Forbidden`.
|
|
||||||
|
|
||||||
**Response:** `204 No Content`
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
**Fehler:**
|
**Fehler:**
|
||||||
- `403 Forbidden` — Benutzer hat nicht die erforderliche Rolle (restricted-Benutzer dürfen Override nicht löschen)
|
|
||||||
- `500 Internal Server Error` — DB-Fehler
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -661,7 +651,6 @@ Setzt oder löscht den per-Screen "Einschalten bis"-Override. Mit diesem Overrid
|
||||||
dem angegebenen Zeitpunkt eingeschaltet, selbst wenn der globale Schedule "aus" vorsieht.
|
dem angegebenen Zeitpunkt eingeschaltet, selbst wenn der globale Schedule "aus" vorsieht.
|
||||||
|
|
||||||
**Auth:** Erforderlich. Screen-Zugriff erforderlich.
|
**Auth:** Erforderlich. Screen-Zugriff erforderlich.
|
||||||
**Rollen:** Nur `admin_user` und `screen_user`. Benutzer mit Rolle `restricted` erhalten `403 Forbidden`.
|
|
||||||
|
|
||||||
**Path-Parameter:**
|
**Path-Parameter:**
|
||||||
- `screenSlug` — Slug des Screens
|
- `screenSlug` — Slug des Screens
|
||||||
|
|
@ -680,7 +669,6 @@ Um den Override zu löschen, `on_until` auf `null` setzen:
|
||||||
|
|
||||||
**Fehler:**
|
**Fehler:**
|
||||||
- `400 Bad Request` — Ungültiges Zeitformat oder ungültiges JSON
|
- `400 Bad Request` — Ungültiges Zeitformat oder ungültiges JSON
|
||||||
- `403 Forbidden` — Benutzer hat nicht die erforderliche Rolle (restricted-Benutzer dürfen Override nicht setzen)
|
|
||||||
- `404 Not Found` — Screen nicht vorhanden
|
- `404 Not Found` — Screen nicht vorhanden
|
||||||
- `500 Internal Server Error` — DB-Fehler
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
|
|
@ -698,17 +686,6 @@ Spezialendpoint zur Auflösung von Nachrichten-Wand-Anfragen (noch in Entwicklun
|
||||||
|
|
||||||
Alle Auth-Routen erfordern keine vorherige Authentifizierung.
|
Alle Auth-Routen erfordern keine vorherige Authentifizierung.
|
||||||
|
|
||||||
### Benutzerrollen
|
|
||||||
|
|
||||||
Das System unterscheidet folgende Rollen:
|
|
||||||
|
|
||||||
- `admin_user` — Volller Zugriff auf alle Funktionen, inkl. Benutzerverwaltung und Display-Steuerung
|
|
||||||
- `screen_user` — Darf Medien hochladen, Playlists bearbeiten und Displays steuern (An/Aus, Zeitplan, Override)
|
|
||||||
- `restricted` — Darf Medien hochladen und Playlist bearbeiten. Keine Display-Steuerung (An/Aus, Zeitplan, Override). Betroffene Endpunkte antworten mit 403 Forbidden.
|
|
||||||
- `tenant_user` — Tenant-Operator für Self-Service-Dashboard (Medienupload, Tenantmenü)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /
|
### GET /
|
||||||
|
|
||||||
Root-Redirect auf `/login`.
|
Root-Redirect auf `/login`.
|
||||||
|
|
|
||||||
|
|
@ -1,672 +0,0 @@
|
||||||
# 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 `<div class="columns is-vcentered">` Block (Zeile 522 bis 547):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="columns is-vcentered">
|
|
||||||
<div class="column is-3">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-small">Benutzername</label>
|
|
||||||
<input class="input is-small" type="text" name="username" placeholder="z.B. alice" required autocomplete="off">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-small">Passwort</label>
|
|
||||||
<div class="control has-icons-right">
|
|
||||||
<input class="input is-small" type="password" id="admin-new-password" name="password" placeholder="Mindestens 8 Zeichen" required autocomplete="new-password" minlength="8">
|
|
||||||
<span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="var i=document.getElementById('admin-new-password');i.type=i.type==='password'?'text':'password'">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="help">Mind. 8 Zeichen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-small">Rolle</label>
|
|
||||||
<div class="control">
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="role">
|
|
||||||
<option value="screen_user">Vollzugriff</option>
|
|
||||||
<option value="restricted">Eingeschränkt (nur Medien/Playlist)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-small"> </label>
|
|
||||||
<button class="button is-primary is-small" type="submit">Benutzer anlegen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Rollen-Badge in der User-Liste hinzufügen**
|
|
||||||
|
|
||||||
In der User-Tabelle (um Zeile 502–510), in der `<td><strong>{{.Username}}</strong></td>`-Zelle die Rolle als Badge ergänzen:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<td>
|
|
||||||
<strong>{{.Username}}</strong>
|
|
||||||
{{if eq .Role "restricted"}}
|
|
||||||
<span class="tag is-warning is-light is-small ml-2">Eingeschränkt</span>
|
|
||||||
{{end}}
|
|
||||||
</td>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Kompilierung prüfen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartetes Ergebnis: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/internal/httpapi/manage/templates.go
|
|
||||||
git commit -m "feat(ui): Admin-Formular: Rolle-Dropdown + Badge in User-Liste"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Template — UI-Guards für restricted-User
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `server/backend/internal/httpapi/manage/templates.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Globalen Override-Banner und Bulk-Leiste in `screenOverviewTmpl` ausblenden**
|
|
||||||
|
|
||||||
Der `screenOverviewTmpl` hat zwei Blöcke, die für restricted ausgeblendet werden müssen:
|
|
||||||
|
|
||||||
**a) Globaler Override-Banner** — Suche den Block der mit `{{if .GlobalOverride}}` beginnt (und davor den "Alle ausschalten"-Button-Bereich). Wrap den gesamten Override-Banner-Bereich:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
... gesamter globaler Override-Bereich (Banner + Buttons) ...
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
**b) Bulk-Steuerleiste** — Suche den Block `{{if gt (len .Cards) 1}}` der die "Alle an/Alle aus"-Buttons enthält. Wrap ihn:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if and (gt (len .Cards) 1) (ne .UserRole "restricted")}}
|
|
||||||
... Bulk-Leiste ...
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: An/Aus-Buttons und per-Screen-Override in Karten ausblenden**
|
|
||||||
|
|
||||||
Innerhalb von `{{range .Cards}}` (Zugriff auf Top-Level-Variable mit `$`):
|
|
||||||
|
|
||||||
Suche den Display-State-Badge-Bereich mit den On/Off-Buttons. Wrap die Buttons (nicht den Badge selbst):
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if ne $.UserRole "restricted"}}
|
|
||||||
<button class="button is-small is-success" onclick="sendDisplayCmd('{{.Screen.Slug}}','on')">Ein</button>
|
|
||||||
<button class="button is-small is-danger is-outlined" onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Wrap den gesamten per-Screen Override Block (`{{if not_expired .OverrideOnUntil}}...{{else}}<details>...{{end}}`):
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if ne $.UserRole "restricted"}}
|
|
||||||
{{if not_expired .OverrideOnUntil}}
|
|
||||||
... override badge + clear button ...
|
|
||||||
{{else}}
|
|
||||||
<details>...</details>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Zeitplan-Box und Override-Box in `manageTmpl` ausblenden**
|
|
||||||
|
|
||||||
In `manageTmpl`:
|
|
||||||
|
|
||||||
**Zeitplan-Box** — Suche den Kasten mit dem Heading "Zeitplan" (enthält `<input type="time">` Felder). Wrap den gesamten `<div class="box">` um die Zeitplan-Box:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<div class="box">
|
|
||||||
... Zeitplan-Inhalt ...
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Override-Box** — Suche den Kasten mit "Einschalten bis (Override)". Wrap analog:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<div class="box">
|
|
||||||
... Override-Inhalt ...
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Kompilierung prüfen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartetes Ergebnis: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Tests laufen lassen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartetes Ergebnis: alle Tests PASS
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/internal/httpapi/manage/templates.go
|
|
||||||
git commit -m "feat(ui): restricted-User sehen keine Steuerungs-UI (An/Aus, Zeitplan, Override)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Dokumentation
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/API-ENDPOINTS.md`
|
|
||||||
- Modify: `server/backend/README.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: `docs/API-ENDPOINTS.md` aktualisieren**
|
|
||||||
|
|
||||||
Ergänze bei den Endpunkten für Display-Steuerung, Zeitplan und Override jeweils eine Zeile:
|
|
||||||
|
|
||||||
> `restricted`-User erhalten 403 Forbidden.
|
|
||||||
|
|
||||||
Abschnitt "Auth / Rollen" (falls vorhanden) um den neuen Rollenwert `restricted` ergänzen.
|
|
||||||
|
|
||||||
- [ ] **Step 2: `server/backend/README.md` aktualisieren**
|
|
||||||
|
|
||||||
Ergänze in der Rollenbeschreibung:
|
|
||||||
|
|
||||||
```
|
|
||||||
- `restricted` — Darf Medien hochladen und Playlist bearbeiten.
|
|
||||||
Keine Display-Steuerung (An/Aus, Zeitplan, Override).
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Kompilierung + Tests finaler Check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./... && go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartetes Ergebnis: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/API-ENDPOINTS.md server/backend/README.md
|
|
||||||
git commit -m "docs: restricted-Rolle in API-Endpoints und README dokumentiert"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
**Spec coverage:**
|
|
||||||
- ✅ Neuer Rollenwert `"restricted"` — Task 2 (`CreateScreenUser` role-Param)
|
|
||||||
- ✅ Middleware `RequireNotRestricted` — Task 1
|
|
||||||
- ✅ Endpunkte display/schedule/override blockiert — Task 3
|
|
||||||
- ✅ Template `UserRole` + Guards — Tasks 4, 5, 6
|
|
||||||
- ✅ Admin-UI Rolle-Dropdown — Task 5
|
|
||||||
- ✅ Bestehende User unberührt (`ListScreenUsers` erweitert, kein Default-Change) — Task 2
|
|
||||||
|
|
||||||
**Placeholder scan:** keine TBDs, alle Code-Blöcke vollständig.
|
|
||||||
|
|
||||||
**Type consistency:** `CreateScreenUser(ctx, tenantSlug, username, password, role string)` — konsistent in Tasks 2 + 4.
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
# Design: Restricted-Rolle für Screen-User
|
|
||||||
|
|
||||||
**Datum:** 2026-03-27
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Zusammenfassung
|
|
||||||
|
|
||||||
Firmen, die einzelne Monitore mieten, erhalten einen User mit der Rolle `"restricted"`. Diese Nutzer dürfen Medien hochladen und die Playlist bearbeiten, aber keine Anzeige-Steuerung (An/Aus, Zeitplan, Override) vornehmen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Datenmodell
|
|
||||||
|
|
||||||
Kein Schema-Change erforderlich. `users.role` ist bereits ein freier String. `"restricted"` wird als dritter gültiger Rollenwert eingeführt (neben `"admin"` und `"screen_user"`).
|
|
||||||
|
|
||||||
`CreateScreenUser()` erhält einen `role`-Parameter (aktuell immer `"screen_user"`), sodass der Admin-Handler beim Anlegen eines Users wahlweise `"screen_user"` oder `"restricted"` übergeben kann.
|
|
||||||
|
|
||||||
Bestehende User behalten ihren bisherigen Wert — kein Migrationsbedarf.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend-Absicherung
|
|
||||||
|
|
||||||
Neue Middleware `RequireNotRestricted` prüft `user.Role != "restricted"` und gibt andernfalls 403 zurück.
|
|
||||||
|
|
||||||
Sie wird auf folgende Endpunkte in `router.go` gelegt (zusätzlich zur bestehenden `authScreen`-Middleware):
|
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
|
||||||
|---------|------|--------------|
|
|
||||||
| `POST` | `/api/v1/screens/{screenSlug}/display` | An/Aus |
|
|
||||||
| `POST` | `/api/v1/screens/{screenSlug}/schedule` | Zeitplan |
|
|
||||||
| `POST` | `/api/v1/screens/{screenSlug}/override` | Per-Screen Override |
|
|
||||||
| `POST` | `/api/v1/global-override` | Globaler Override setzen |
|
|
||||||
| `DELETE` | `/api/v1/global-override` | Globaler Override löschen |
|
|
||||||
|
|
||||||
**Erlaubt bleiben für `restricted`:**
|
|
||||||
- `POST /manage/{screenSlug}/upload` — Medien hochladen
|
|
||||||
- `POST /manage/{screenSlug}/items` — Playlist-Eintrag hinzufügen
|
|
||||||
- `POST /manage/{screenSlug}/reorder` — Playlist sortieren
|
|
||||||
- `DELETE /manage/{screenSlug}/items/{id}` — Playlist-Eintrag entfernen
|
|
||||||
- Alle lesenden Endpunkte
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
Der Handler übergibt die Rolle des eingeloggten Users als `UserRole string` im Template-Daten-Struct (`screenOverviewData` und `manageData`).
|
|
||||||
|
|
||||||
Das Template blendet per `{{if ne .UserRole "restricted"}}` aus:
|
|
||||||
|
|
||||||
**Übersicht (`/manage`):**
|
|
||||||
- Globaler Override-Banner (komplett)
|
|
||||||
- Bulk-Steuerleiste ("Alle an/aus")
|
|
||||||
- Pro Monitorkarte: An/Aus-Buttons, per-Screen-Override-Widget
|
|
||||||
|
|
||||||
**Detailseite (`/manage/{slug}`):**
|
|
||||||
- Zeitplan-Box
|
|
||||||
- Override-Box ("Einschalten bis")
|
|
||||||
|
|
||||||
Medien-Upload und Playlist bleiben unverändert sichtbar und bedienbar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin-UI
|
|
||||||
|
|
||||||
Beim Anlegen eines Screen-Users (`POST /admin/screens/{slug}/users`) gibt es ein neues `<select>`-Feld mit den Optionen:
|
|
||||||
- `screen_user` → "Vollzugriff" (default, bisheriges Verhalten)
|
|
||||||
- `restricted` → "Eingeschränkt (nur Medien/Playlist)"
|
|
||||||
|
|
||||||
Der gewählte Wert wird als `role`-Parameter an `CreateScreenUser()` übergeben.
|
|
||||||
|
|
||||||
Nachträgliches Ändern der Rolle ist nicht vorgesehen — wer die Rolle ändern möchte, legt den User neu an.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nicht im Scope
|
|
||||||
|
|
||||||
- Per-Screen-Rollenmischung (User X hat auf Screen A Vollzugriff, auf Screen B restricted) — nicht benötigt
|
|
||||||
- Nachträgliches Bearbeiten der Rolle eines bestehenden Users
|
|
||||||
- Anzeige der Rolle in der User-Liste (YAGNI)
|
|
||||||
|
|
@ -11,13 +11,6 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
|
||||||
- Player-Status-Ingest und Diagnose
|
- Player-Status-Ingest und Diagnose
|
||||||
- MQTT-Notifizierungen bei Playlist-Aenderungen
|
- MQTT-Notifizierungen bei Playlist-Aenderungen
|
||||||
|
|
||||||
## Benutzerrollen
|
|
||||||
|
|
||||||
- `admin_user` — Volller Zugriff auf alle Funktionen
|
|
||||||
- `screen_user` — Darf Medien hochladen und Playlist bearbeiten. Display-Steuerung (An/Aus, Zeitplan, Override)
|
|
||||||
- `restricted` — Darf Medien hochladen und Playlist bearbeiten. Keine Display-Steuerung.
|
|
||||||
- `tenant_user` — Tenant-Operator für Self-Service-Dashboard
|
|
||||||
|
|
||||||
## Unterstruktur
|
## Unterstruktur
|
||||||
|
|
||||||
- `cmd/api/` — Startpunkt des Backends
|
- `cmd/api/` — Startpunkt des Backends
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,7 @@ const adminTmpl = `<!DOCTYPE html>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .ScreenUsers}}
|
{{range .ScreenUsers}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{.Username}}</strong>{{if eq .Role "restricted"}} <span class="tag is-warning is-light is-small ml-2">Eingeschränkt</span>{{end}}</td>
|
<td><strong>{{.Username}}</strong></td>
|
||||||
<td class="has-text-grey" style="font-size:.875rem">{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
|
<td class="has-text-grey" style="font-size:.875rem">{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
|
||||||
<td style="text-align:right">
|
<td style="text-align:right">
|
||||||
<button class="button is-small is-danger is-outlined" type="button"
|
<button class="button is-small is-danger is-outlined" type="button"
|
||||||
|
|
@ -520,13 +520,13 @@ const adminTmpl = `<!DOCTYPE html>
|
||||||
<h3 class="title is-6 mb-3">Neuen Benutzer anlegen</h3>
|
<h3 class="title is-6 mb-3">Neuen Benutzer anlegen</h3>
|
||||||
<form method="POST" action="/admin/users">
|
<form method="POST" action="/admin/users">
|
||||||
<div class="columns is-vcentered">
|
<div class="columns is-vcentered">
|
||||||
<div class="column is-3">
|
<div class="column is-4">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-small">Benutzername</label>
|
<label class="label is-small">Benutzername</label>
|
||||||
<input class="input is-small" type="text" name="username" placeholder="z.B. alice" required autocomplete="off">
|
<input class="input is-small" type="text" name="username" placeholder="z.B. alice" required autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-3">
|
<div class="column is-4">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-small">Passwort</label>
|
<label class="label is-small">Passwort</label>
|
||||||
<div class="control has-icons-right">
|
<div class="control has-icons-right">
|
||||||
|
|
@ -538,20 +538,7 @@ const adminTmpl = `<!DOCTYPE html>
|
||||||
<p class="help">Mind. 8 Zeichen</p>
|
<p class="help">Mind. 8 Zeichen</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-3">
|
<div class="column is-4">
|
||||||
<div class="field">
|
|
||||||
<label class="label is-small">Rolle</label>
|
|
||||||
<div class="control">
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="role">
|
|
||||||
<option value="screen_user">Vollzugriff</option>
|
|
||||||
<option value="restricted">Eingeschränkt (nur Medien/Playlist)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-small"> </label>
|
<label class="label is-small"> </label>
|
||||||
<button class="button is-primary is-small" type="submit">Benutzer anlegen</button>
|
<button class="button is-primary is-small" type="submit">Benutzer anlegen</button>
|
||||||
|
|
@ -957,7 +944,6 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
<!-- RIGHT: Library + Upload -->
|
<!-- RIGHT: Library + Upload -->
|
||||||
<div>
|
<div>
|
||||||
<!-- Display control -->
|
<!-- Display control -->
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<div class="box mb-3">
|
<div class="box mb-3">
|
||||||
<h3 class="title is-6 mb-3">Display</h3>
|
<h3 class="title is-6 mb-3">Display</h3>
|
||||||
<div class="display-ctrl">
|
<div class="display-ctrl">
|
||||||
|
|
@ -970,9 +956,7 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
onclick="sendDisplayCmd('off')">Ausschalten</button>
|
onclick="sendDisplayCmd('off')">Ausschalten</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
<!-- Schedule control -->
|
<!-- Schedule control -->
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<div class="box mb-3">
|
<div class="box mb-3">
|
||||||
<h3 class="title is-6 mb-2">Zeitplan</h3>
|
<h3 class="title is-6 mb-2">Zeitplan</h3>
|
||||||
<label class="pl-toggle" title="Zeitplan aktivieren">
|
<label class="pl-toggle" title="Zeitplan aktivieren">
|
||||||
|
|
@ -999,9 +983,7 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
<p id="schedule-save-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
<p id="schedule-save-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
<!-- Per-Screen Override (Einschalten bis) -->
|
<!-- Per-Screen Override (Einschalten bis) -->
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<div class="box mb-3">
|
<div class="box mb-3">
|
||||||
<h3 class="title is-6 mb-2">Einschalten bis (Override)</h3>
|
<h3 class="title is-6 mb-2">Einschalten bis (Override)</h3>
|
||||||
{{if not_expired .Schedule.OverrideOnUntil}}
|
{{if not_expired .Schedule.OverrideOnUntil}}
|
||||||
|
|
@ -1022,7 +1004,6 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p id="screen-override-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
<p id="screen-override-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
<!-- Upload (collapsed) -->
|
<!-- Upload (collapsed) -->
|
||||||
<div class="box mb-3">
|
<div class="box mb-3">
|
||||||
<details id="upload-details">
|
<details id="upload-details">
|
||||||
|
|
@ -1461,7 +1442,6 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
||||||
<!-- Globaler Override-Banner -->
|
<!-- Globaler Override-Banner -->
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<div id="global-override-section" style="margin-bottom:1rem">
|
<div id="global-override-section" style="margin-bottom:1rem">
|
||||||
{{if .GlobalOverride}}
|
{{if .GlobalOverride}}
|
||||||
<div class="notification {{if eq .GlobalOverride.Type "off"}}is-warning{{else}}is-info{{end}} is-light py-2 px-3" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
<div class="notification {{if eq .GlobalOverride.Type "off"}}is-warning{{else}}is-info{{end}} is-light py-2 px-3" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||||
|
|
@ -1484,8 +1464,7 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{if gt (len .Cards) 1}}
|
||||||
{{if and (gt (len .Cards) 1) (ne .UserRole "restricted")}}
|
|
||||||
<div class="bulk-bar">
|
<div class="bulk-bar">
|
||||||
<span style="font-size:.875rem;font-weight:600;color:#374151">Alle Displays:</span>
|
<span style="font-size:.875rem;font-weight:600;color:#374151">Alle Displays:</span>
|
||||||
<button class="button is-small is-success is-light" type="button" onclick="bulkDisplay('on')">Alle einschalten</button>
|
<button class="button is-small is-success is-light" type="button" onclick="bulkDisplay('on')">Alle einschalten</button>
|
||||||
|
|
@ -1512,16 +1491,13 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
<span id="ds-{{.Screen.Slug}}" class="display-state-badge {{.DisplayState}}">
|
<span id="ds-{{.Screen.Slug}}" class="display-state-badge {{.DisplayState}}">
|
||||||
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}?{{end}}
|
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}?{{end}}
|
||||||
</span>
|
</span>
|
||||||
{{if ne $.UserRole "restricted"}}
|
|
||||||
<button class="button is-small is-success is-light" type="button"
|
<button class="button is-small is-success is-light" type="button"
|
||||||
onclick="sendDisplayCmd('{{.Screen.Slug}}','on')">Ein</button>
|
onclick="sendDisplayCmd('{{.Screen.Slug}}','on')">Ein</button>
|
||||||
<button class="button is-small is-danger is-light" type="button"
|
<button class="button is-small is-danger is-light" type="button"
|
||||||
onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
|
onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Per-Screen Override -->
|
<!-- Per-Screen Override -->
|
||||||
<div style="margin-top:.5rem;font-size:.8rem">
|
<div style="margin-top:.5rem;font-size:.8rem">
|
||||||
{{if ne $.UserRole "restricted"}}
|
|
||||||
{{if .OverrideOnUntil}}
|
{{if .OverrideOnUntil}}
|
||||||
<span style="color:#059669">⏰ Ein bis {{.OverrideOnUntil.Format "02.01. 15:04"}}</span>
|
<span style="color:#059669">⏰ Ein bis {{.OverrideOnUntil.Format "02.01. 15:04"}}</span>
|
||||||
<button class="button is-small is-light" style="padding:0 .4rem;height:1.4rem" type="button"
|
<button class="button is-small is-light" style="padding:0 .4rem;height:1.4rem" type="button"
|
||||||
|
|
@ -1537,7 +1513,6 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleCreateScreenUser creates a new screen user (role: screen_user or restricted) for the default tenant.
|
// HandleCreateScreenUser creates a new screen_user for the default tenant.
|
||||||
func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc {
|
func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
|
|
@ -215,17 +215,12 @@ func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
role := r.FormValue("role")
|
|
||||||
if role != "screen_user" && role != "restricted" {
|
|
||||||
role = "screen_user"
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantSlug := "morz"
|
tenantSlug := "morz"
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
|
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
|
||||||
tenantSlug = u.TenantSlug
|
tenantSlug = u.TenantSlug
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password, role)
|
_, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("create screen user failed", "event", "create_screen_user_failed",
|
slog.Error("create screen user failed", "event", "create_screen_user_failed",
|
||||||
"tenant_slug", tenantSlug, "username", username, "err", err)
|
"tenant_slug", tenantSlug, "username", username, "err", err)
|
||||||
|
|
@ -342,7 +337,6 @@ func HandleScreenOverview(screens *store.ScreenStore, schedules *store.ScreenSch
|
||||||
"Cards": cards,
|
"Cards": cards,
|
||||||
"CSRFToken": csrfToken,
|
"CSRFToken": csrfToken,
|
||||||
"GlobalOverride": activeOverride,
|
"GlobalOverride": activeOverride,
|
||||||
"UserRole": u.Role,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -439,7 +433,7 @@ func HandleManageUI(
|
||||||
case "admin":
|
case "admin":
|
||||||
isAdmin = true
|
isAdmin = true
|
||||||
accessibleScreens, _ = screens.ListAll(r.Context())
|
accessibleScreens, _ = screens.ListAll(r.Context())
|
||||||
case "screen_user", "restricted":
|
case "screen_user":
|
||||||
accessibleScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID)
|
accessibleScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID)
|
||||||
default:
|
default:
|
||||||
// tenant_user und ähnliche Rollen: alle Screens des eigenen Tenants.
|
// tenant_user und ähnliche Rollen: alle Screens des eigenen Tenants.
|
||||||
|
|
@ -456,11 +450,6 @@ func HandleManageUI(
|
||||||
serverTimezone = time.Now().Location().String()
|
serverTimezone = time.Now().Location().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
userRole := ""
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
|
||||||
userRole = u.Role
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTemplate(w, t, map[string]any{
|
renderTemplate(w, t, map[string]any{
|
||||||
"Screen": screen,
|
"Screen": screen,
|
||||||
"Tenant": tenant,
|
"Tenant": tenant,
|
||||||
|
|
@ -476,7 +465,6 @@ func HandleManageUI(
|
||||||
"CSRFToken": csrfToken,
|
"CSRFToken": csrfToken,
|
||||||
"DisplayState": displayState,
|
"DisplayState": displayState,
|
||||||
"Schedule": schedule,
|
"Schedule": schedule,
|
||||||
"UserRole": userRole,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,20 +55,6 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireTenantAccess is middleware that allows access only when the
|
// RequireTenantAccess is middleware that allows access only when the
|
||||||
// authenticated user belongs to the tenant identified by the {tenantSlug}
|
// authenticated user belongs to the tenant identified by the {tenantSlug}
|
||||||
// path value, or when the user has role "admin" (admins can access everything).
|
// path value, or when the user has role "admin" (admins can access everything).
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -141,16 +141,6 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
authScreen := func(h http.Handler) http.Handler {
|
authScreen := func(h http.Handler) http.Handler {
|
||||||
return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), setCSRF, csrf)
|
return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), setCSRF, csrf)
|
||||||
}
|
}
|
||||||
// authScreenControl: wie authScreen, aber restricted-User werden mit 403 blockiert.
|
|
||||||
// Für Endpunkte, die restricted-User nicht nutzen dürfen (Display, Zeitplan, Override).
|
|
||||||
authScreenControl := func(h http.Handler) http.Handler {
|
|
||||||
return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), RequireNotRestricted, setCSRF, csrf)
|
|
||||||
}
|
|
||||||
// authOnlyControl: wie authOnly, aber restricted-User werden mit 403 blockiert.
|
|
||||||
// Für globalen Override (kein spezifischer Screen).
|
|
||||||
authOnlyControl := func(h http.Handler) http.Handler {
|
|
||||||
return chain(h, RequireAuth(d.AuthStore), RequireNotRestricted, setCSRF, csrf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin UI ──────────────────────────────────────────────────────────
|
// ── Admin UI ──────────────────────────────────────────────────────────
|
||||||
mux.Handle("GET /admin",
|
mux.Handle("GET /admin",
|
||||||
|
|
@ -197,23 +187,23 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
|
|
||||||
// ── Display control ───────────────────────────────────────────────────
|
// ── Display control ───────────────────────────────────────────────────
|
||||||
mux.Handle("POST /api/v1/screens/{screenSlug}/display",
|
mux.Handle("POST /api/v1/screens/{screenSlug}/display",
|
||||||
authScreenControl(http.HandlerFunc(manage.HandleDisplayCommand(notifier))))
|
authScreen(http.HandlerFunc(manage.HandleDisplayCommand(notifier))))
|
||||||
|
|
||||||
// ── Schedule control ──────────────────────────────────────────────────
|
// ── Schedule control ──────────────────────────────────────────────────
|
||||||
mux.Handle("POST /api/v1/screens/{screenSlug}/schedule",
|
mux.Handle("POST /api/v1/screens/{screenSlug}/schedule",
|
||||||
authScreenControl(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore))))
|
authScreen(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore))))
|
||||||
|
|
||||||
// ── Globaler Override ────────────────────────────────────────────────
|
// ── Globaler Override ────────────────────────────────────────────────
|
||||||
mux.Handle("GET /api/v1/global-override",
|
mux.Handle("GET /api/v1/global-override",
|
||||||
authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore))))
|
authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore))))
|
||||||
mux.Handle("POST /api/v1/global-override",
|
mux.Handle("POST /api/v1/global-override",
|
||||||
authOnlyControl(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier))))
|
authOnly(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier))))
|
||||||
mux.Handle("DELETE /api/v1/global-override",
|
mux.Handle("DELETE /api/v1/global-override",
|
||||||
authOnlyControl(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore))))
|
authOnly(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore))))
|
||||||
|
|
||||||
// ── Per-Screen Override ───────────────────────────────────────────────
|
// ── Per-Screen Override ───────────────────────────────────────────────
|
||||||
mux.Handle("POST /api/v1/screens/{screenSlug}/override",
|
mux.Handle("POST /api/v1/screens/{screenSlug}/override",
|
||||||
authScreenControl(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore))))
|
authScreen(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore))))
|
||||||
|
|
||||||
// ── JSON API — screens ────────────────────────────────────────────────
|
// ── JSON API — screens ────────────────────────────────────────────────
|
||||||
// Self-registration: no auth (player calls this on startup).
|
// Self-registration: no auth (player calls this on startup).
|
||||||
|
|
|
||||||
|
|
@ -155,15 +155,11 @@ func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password st
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateScreenUser creates a new user with the given role for the tenant
|
// CreateScreenUser creates a new user with role 'screen_user' for the tenant
|
||||||
// identified by tenantSlug. role must be "screen_user" or "restricted".
|
// identified by tenantSlug. The password is hashed with bcrypt (cost 12).
|
||||||
// The password is hashed with bcrypt (cost 12).
|
|
||||||
// Returns pgx.ErrNoRows if the tenant does not exist, or a wrapped error if
|
// Returns pgx.ErrNoRows if the tenant does not exist, or a wrapped error if
|
||||||
// the username is already taken (unique constraint violation).
|
// the username is already taken (unique constraint violation).
|
||||||
func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password, role string) (*User, error) {
|
func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password string) (*User, error) {
|
||||||
if role != "screen_user" && role != "restricted" {
|
|
||||||
return nil, fmt.Errorf("auth: invalid role: %s", role)
|
|
||||||
}
|
|
||||||
var tenantID string
|
var tenantID string
|
||||||
err := s.pool.QueryRow(ctx, `select id from tenants where slug = $1`, tenantSlug).Scan(&tenantID)
|
err := s.pool.QueryRow(ctx, `select id from tenants where slug = $1`, tenantSlug).Scan(&tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -180,9 +176,9 @@ func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username,
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`insert into users(tenant_id, username, password_hash, role)
|
`insert into users(tenant_id, username, password_hash, role)
|
||||||
values($1, $2, $3, $4)
|
values($1, $2, $3, 'screen_user')
|
||||||
returning id, tenant_id, $5::text, username, password_hash, role, created_at`,
|
returning id, tenant_id, $4::text, username, password_hash, role, created_at`,
|
||||||
tenantID, username, string(hash), role, tenantSlug)
|
tenantID, username, string(hash), tenantSlug)
|
||||||
u, err := scanUserWithSlug(row)
|
u, err := scanUserWithSlug(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("auth: create screen user: %w", err)
|
return nil, fmt.Errorf("auth: create screen user: %w", err)
|
||||||
|
|
@ -190,13 +186,13 @@ func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username,
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListScreenUsers returns all users with role 'screen_user' or 'restricted' for the given tenant.
|
// ListScreenUsers returns all users with role 'screen_user' for the given tenant.
|
||||||
func (s *AuthStore) ListScreenUsers(ctx context.Context, tenantSlug string) ([]*User, error) {
|
func (s *AuthStore) ListScreenUsers(ctx context.Context, tenantSlug string) ([]*User, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
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
|
`select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at
|
||||||
from users u
|
from users u
|
||||||
left join tenants t on t.id = u.tenant_id
|
left join tenants t on t.id = u.tenant_id
|
||||||
where t.slug = $1 and u.role IN ('screen_user', 'restricted')
|
where t.slug = $1 and u.role = 'screen_user'
|
||||||
order by u.username`, tenantSlug)
|
order by u.username`, tenantSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("auth: list screen users: %w", err)
|
return nil, fmt.Errorf("auth: list screen users: %w", err)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue