morz-infoboard/docs/superpowers/plans/2026-03-27-restricted-rolle.md
Jesko Anschütz e0ea7f0bde docs: Implementierungsplan für restricted-Rolle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:26:54 +01:00

672 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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.