diff --git a/CLAUDE.md b/CLAUDE.md index 8e5b7ae..1d86fa3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,21 @@ ist der Nachfolger dessen, was früher (vor dem Produktwechsel) Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware je Handler), nicht als grob unterschiedene Seitenbereiche. +**Ebene 4 — Nutzerverwaltung ist umgesetzt** (`internal/web/admin_handlers.go`, +Middleware `requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins +des eigenen Mandanten, `GET/POST /verwaltung/nutzer/neu` legt einen +weiteren Login mit einer der vier Mandanten-Rollen an (`mitarbeiter`, +`verantwortlicher`, `pruefer`, `admin` — `betreiber` kann kein +Mandanten-Admin vergeben, das ist Ebene 5). Es gibt noch **keine** +Einladungsmail — der Admin setzt das Initialpasswort direkt im +Formular und gibt es auf einem anderen Weg weiter (konsistent mit dem +Onboarding-Stand unten: Einladungslink/CSV-Import/SSO sind noch nicht +gebaut). Das schließt die Lücke, dass Ebene 3 (Fachebene) bisher nur +über einen manuellen SQL-Insert nutzbar war, weil die Firma- +Registrierung ausschließlich einen `admin`-Nutzer erzeugt. Abteilungen- +Verwaltung, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen und +Rechnungsdaten (Ebene 4 laut Tabelle oben) sind weiterhin nicht gebaut. + **Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird Isolation in der Anwendungsschicht erzwungen (Handler vergleichen `AccountID` gegen den angemeldeten Account, wie bisher) — die diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go new file mode 100644 index 0000000..ba09181 --- /dev/null +++ b/internal/web/admin_handlers.go @@ -0,0 +1,108 @@ +// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen für GENAU EINEN +// Mandanten, siehe CLAUDE.md, Fünf Ebenen. Aktuell nur Nutzerverwaltung +// (Abteilungen, Anmeldeverfahren, Werkzeug-Freigaben, Rechnungsdaten +// sind spätere Ausbaustufen). Es gibt noch keinen Einladungslink/CSV- +// Import/SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier +// direkt ein Initialpasswort, wie schon bei der Firma-Registrierung. +package web + +import ( + "net/http" + + "github.com/netcell-it/deklarix/internal/auth" +) + +// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen +// Mandanten vergeben darf — "betreiber" ist Ebene 5 (Plattform) und +// kann von keinem Mandanten-Admin vergeben werden. +var mandantenRollen = []string{"mitarbeiter", "verantwortlicher", "pruefer", "admin"} + +func istMandantenRolle(role string) bool { + for _, r := range mandantenRollen { + if r == role { + return true + } + } + return false +} + +type nutzerListItem struct { + Email string + Role string +} + +type nutzerListeData struct { + Title string + Nav navData + Nutzer []nutzerListItem +} + +// handleNutzerListe zeigt alle Logins des eigenen Mandanten. +func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) { + users, err := s.store.ListUsersForAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)} + for _, u := range users { + data.Nutzer = append(data.Nutzer, nutzerListItem{Email: u.Email, Role: u.Role}) + } + if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type nutzerNeuData struct { + Title string + Nav navData + Rollen []string + Error string +} + +func (s *Server) handleNutzerNeuForm(w http.ResponseWriter, r *http.Request) { + data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen} + if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleNutzerCreate legt einen weiteren Login im eigenen Mandanten an. +// Es gibt keine Einladungsmail (siehe CLAUDE.md, Onboarding — noch +// nicht gebaut) — der Admin setzt das Initialpasswort direkt und gibt +// es dem/der neuen Nutzer:in auf einem anderen Weg weiter. +func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + email := r.FormValue("email") + password := r.FormValue("password") + role := r.FormValue("role") + if email == "" || password == "" || !istMandantenRolle(role) { + data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "E-Mail, Passwort und eine gültige Rolle sind Pflicht"} + if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } + return + } + + passwordHash, err := auth.HashPassword(password) + if err != nil { + data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: err.Error()} + if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } + return + } + + if _, err := s.store.CreateUser(r.Context(), currentUser(r).AccountID, email, passwordHash, role); err != nil { + data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"} + if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } + return + } + + http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther) +} diff --git a/internal/web/admin_handlers_test.go b/internal/web/admin_handlers_test.go new file mode 100644 index 0000000..9154fbe --- /dev/null +++ b/internal/web/admin_handlers_test.go @@ -0,0 +1,75 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestMitarbeiterCannotAccessNutzerverwaltung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter") + + resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter on /verwaltung/nutzer", resp.Code) + } +} + +func TestAdminCanCreateUserWithMandantenRolle(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{ + "email": {"neu@example.com"}, "password": {"ein-langes-passwort"}, "role": {"verantwortlicher"}, + }) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) + } + + u, err := fs.GetUserByEmail(context.Background(), "neu@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if u.Role != "verantwortlicher" { + t.Errorf("Role = %q, want verantwortlicher", u.Role) + } + + list := getWithCookie(t, s, adminCookie, "/verwaltung/nutzer") + if !strings.Contains(list.Body.String(), "neu@example.com") { + t.Errorf("expected the new user in the Nutzerliste, got: %s", list.Body.String()) + } +} + +func TestAdminCannotCreateBetreiberUser(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{ + "email": {"versuch@example.com"}, "password": {"ein-langes-passwort"}, "role": {"betreiber"}, + }) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (Formular mit Fehler) for role betreiber", resp.Code) + } + if _, err := fs.GetUserByEmail(context.Background(), "versuch@example.com"); err == nil { + t.Error("expected no user to be created for an invalid role") + } +} + +func TestAdminCreateUserRejectsDuplicateEmail(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{ + "email": {"admin@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"}, + }) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (Formular mit Fehler) for duplicate email", resp.Code) + } +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 86cb158..023a730 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -112,6 +112,24 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { } } +// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/ +// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei +// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle. +func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := s.authenticate(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if user.Role != "admin" { + http.Error(w, "nicht gefunden", http.StatusNotFound) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + } +} + // navData steuert die gemeinsame Navigation (layout.html, "nav"-Block). // Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen // currentUser zu geben — die Navigation braucht nur, ob ein @@ -119,6 +137,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { type navData struct { IsBetreiber bool IsFachebene bool + IsAdmin bool } // navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage. @@ -126,7 +145,11 @@ type navData struct { // currentUser). func navFor(r *http.Request) navData { role := currentUser(r).Role - return navData{IsBetreiber: role == "betreiber", IsFachebene: role == "verantwortlicher" || role == "pruefer"} + return navData{ + IsBetreiber: role == "betreiber", + IsFachebene: role == "verantwortlicher" || role == "pruefer", + IsAdmin: role == "admin", + } } // currentUser liest den Nutzer, den requirePage/requireAPI in den diff --git a/internal/web/server.go b/internal/web/server.go index 2bc0cd5..bc61c39 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -106,6 +106,9 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe)) mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail)) mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden)) + mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe)) + mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm)) + mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 5f4d40d..72940db 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -11,6 +11,7 @@ Start {{if not .IsBetreiber}}Meine Anträge{{end}} {{if .IsFachebene}}Posteingang{{end}} + {{if .IsAdmin}}Nutzerverwaltung{{end}} {{if .IsBetreiber}}Plattform{{end}}