// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für // GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren, // Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin // 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" "strings" "github.com/netcell-it/deklarix/internal/auth" "github.com/netcell-it/deklarix/internal/store" ) // 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 { ID string Email string Role string Active bool IstSelbst bool } type nutzerListeData struct { Title string Nav navData Nutzer []nutzerListItem Suche string Pagination pagination } // handleNutzerListe zeigt alle Logins des eigenen Mandanten. func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) { self := currentUser(r) users, err := s.store.ListUsersForAccount(r.Context(), self.AccountID) if err != nil { http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) return } q := strings.TrimSpace(r.URL.Query().Get("q")) var gefiltert []store.User for _, u := range users { if matchesQuery(q, u.Email, u.Role) { gefiltert = append(gefiltert, u) } } pag := paginationFromRequest(r, len(gefiltert)) data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r), Suche: q, Pagination: pag} for _, u := range paginate(gefiltert, pag) { data.Nutzer = append(data.Nutzer, nutzerListItem{ ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID, }) } if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } // handleNutzerDeaktivieren sperrt einen Login des eigenen Mandanten — // nicht löschen, siehe store.User (Fremdschlüssel auf antrag/entscheidung/ // audit_log würden das verhindern und die Historie zerstören). Ein Admin // kann sich nicht selbst deaktivieren, sonst könnte ein Mandant sich // versehentlich komplett aussperren. func (s *Server) handleNutzerDeaktivieren(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") self := currentUser(r) if id == self.ID { http.Error(w, "Der eigene Zugang kann nicht deaktiviert werden", http.StatusBadRequest) return } target, err := s.store.GetUser(r.Context(), id) if err != nil || target.AccountID != self.AccountID { http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound) return } if err := s.store.SetUserActive(r.Context(), id, false); err != nil { http.Error(w, "Nutzer konnte nicht deaktiviert werden: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther) } func (s *Server) handleNutzerAktivieren(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") target, err := s.store.GetUser(r.Context(), id) if err != nil || target.AccountID != currentUser(r).AccountID { http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound) return } if err := s.store.SetUserActive(r.Context(), id, true); err != nil { http.Error(w, "Nutzer konnte nicht aktiviert werden: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther) } 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) } type abteilungListItem struct { ID string Name string } type abteilungListeData struct { Title string Nav navData Abteilungen []abteilungListItem Error string } // handleAbteilungListe zeigt alle Abteilungen des eigenen Mandanten // mit einem Formular zum Anlegen weiterer — bewusst eine Seite statt // Liste+Formular getrennt, da Abteilungen reine Stammdaten ohne // weitere Felder sind (nur `name`). func (s *Server) handleAbteilungListe(w http.ResponseWriter, r *http.Request) { abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID) if err != nil { http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) return } data := abteilungListeData{Title: "Abteilungen", Nav: navFor(r)} for _, a := range abteilungen { data.Abteilungen = append(data.Abteilungen, abteilungListItem{ID: a.ID, Name: a.Name}) } if err := s.templates.ExecuteTemplate(w, "abteilung-liste", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } func (s *Server) handleAbteilungCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "ungültiges Formular", http.StatusBadRequest) return } name := r.FormValue("name") if name == "" { http.Error(w, "Name ist Pflicht", http.StatusBadRequest) return } if _, err := s.store.CreateAbteilung(r.Context(), currentUser(r).AccountID, name); err != nil { http.Error(w, "Abteilung konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther) } // handleAbteilungDelete entfernt eine Abteilung des eigenen Mandanten. // Fremde Abteilungen (anderer Mandant) sind über die ID nicht löschbar // — GetAbteilung prüft AccountID gegen den angemeldeten Mandanten, // bevor gelöscht wird. func (s *Server) handleAbteilungDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") abt, err := s.store.GetAbteilung(r.Context(), id) if err != nil || abt.AccountID != currentUser(r).AccountID { http.Error(w, "Abteilung nicht gefunden", http.StatusNotFound) return } if err := s.store.DeleteAbteilung(r.Context(), id); err != nil { http.Error(w, "Abteilung konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther) }