docs: Implementierungsplan für restricted-Rolle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-27 21:26:54 +01:00
parent c5f222cad8
commit e0ea7f0bde

View file

@ -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 162187):
```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 191210) 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 204232):
```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 521548). 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">&nbsp;</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 502510), 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.