Deklarix soll ein buchbarer Service werden — dafür muss jede Entität im
Datenmodell über das Frontend steuerbar sein, nicht nur einsehbar.
Schließt drei konkrete Lücken:
- Abteilungen (GET /verwaltung/abteilungen, anlegen/löschen) — ohne
diese Seite blieb die Abteilung-Auswahl im Antrag-Fragebogen leer
und unbenutzbar, das war ein Funktionsdefizit, kein Komfortfehler.
- Eigene Werkzeug-Sperrungen (GET /verwaltung/werkzeuge) — ein Mandant
kann einen zentralen Katalogeintrag jetzt für sich sperren/entsperren,
ohne den zentralen Katalog selbst zu verändern.
- Nutzer-Deaktivierung (POST /verwaltung/nutzer/{id}/deaktivieren bzw.
.../aktivieren, neue Spalte app_user.active, Migration 0012). Nutzer
werden nicht gelöscht (Fremdschlüssel auf antrag/entscheidung/
audit_log würden das verhindern und die Historie zerstören) —
deaktivierte Logins können sich nicht mehr anmelden und verlieren
eine laufende Sitzung sofort. Ein Admin kann sich nicht selbst
deaktivieren.
Zusätzlich: store.ListAktiveGenehmigungenForAccount als Grundlage für
die Wiedervorlage (Schritt 7, Web-Layer folgt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
8.1 KiB
Go
219 lines
8.1 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"
|
|
|
|
"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 {
|
|
ID string
|
|
Email string
|
|
Role string
|
|
Active bool
|
|
IstSelbst bool
|
|
}
|
|
|
|
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) {
|
|
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
|
|
}
|
|
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
|
|
for _, u := range users {
|
|
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)
|
|
}
|