Files
deklarix/internal/web/admin_handlers.go
noroot 4c60603055 feat: PageHeader/Filter/Paginierung auf Register, Nutzerverwaltung, Abteilungen, Posteingang, Betreiber-Dashboard ausgerollt
Register und Fälle-Posteingang bekommen .page-header, Freitext-Filter
und Paginierung; Register zusätzlich .table-responsive statt
.table-scroll. Nutzerverwaltung bekommt Filter+Paginierung (bleibt
Karten-Liste). Abteilungen nur .page-header (bewusst ohne Paginierung,
typischerweise kleine Listen). Betreiber-Dashboard von .admin-kacheln
auf .stat-cards umgestellt.

Neuer gemeinsamer Helfer matchesQuery() in pagination.go für die
Freitext-Filter aller Listen-Seiten.

Dabei einen bereits live gewesenen CSS-Bug gefunden und behoben:
.filter-bar direkt auf einem <form> verlor gegen "form {
flex-direction: column }" (gleiche Spezifität, nur form setzte die
Property), wodurch die Filterleiste senkrecht statt waagerecht
stapelte — betraf die im letzten Schritt ausgelieferte
Mandanten-Werkzeugkatalog-Seite. Jetzt robust via explizitem
flex-direction: row in .filter-bar selbst.
2026-08-31 16:12:41 +02:00

231 lines
8.5 KiB
Go

// 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)
}