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.
231 lines
8.5 KiB
Go
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)
|
|
}
|