diff --git a/CLAUDE.md b/CLAUDE.md index f69a7ce..5185d0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,10 +334,19 @@ jederzeit erneuerbar [macht den alten Link sofort ungültig]. mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an, ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung wählt die Person weiterhin erst beim ersten Antrag, nicht bei der -Einladung.), (2) CSV-Import, (3) SSO (Entra ID/Google Workspace), -(4) Antrag ganz ohne vorheriges Konto über Mandantenlink + E-Mail- -Bestätigungscode (Konto entsteht nebenbei). (2)-(4) sind weiterhin -nicht gebaut. +Einladung.), (2) ~~CSV-Import~~ — **erledigt** (`internal/web/csv_import_handlers.go`, +`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte +"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim +einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird +ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`], +das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und +danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import: +gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits +vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen — +jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].), +(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges +Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht +nebenbei). (3)-(4) sind weiterhin nicht gebaut. **Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht** nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende diff --git a/internal/web/csv_import_handlers.go b/internal/web/csv_import_handlers.go new file mode 100644 index 0000000..1ad3b0e --- /dev/null +++ b/internal/web/csv_import_handlers.go @@ -0,0 +1,184 @@ +// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten +// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine +// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie +// beim einzelnen "Nutzer anlegen" generiert der Import ein +// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG +// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern: +// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet +// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer +// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren). +package web + +import ( + "encoding/csv" + "fmt" + "io" + "net/http" + "strings" + + "github.com/netcell-it/deklarix/internal/auth" +) + +type csvMitarbeiterZeile struct { + Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen + Email string + Role string +} + +// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile. +// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder +// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst +// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine +// einzelne falsche Rolle nicht den ganzen Import verhindert). +func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) { + cr := csv.NewReader(r) + cr.TrimLeadingSpace = true + cr.FieldsPerRecord = -1 + + header, err := cr.Read() + if err != nil { + return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err) + } + emailCol, roleCol := -1, -1 + for i, h := range header { + switch strings.ToLower(strings.TrimSpace(h)) { + case "email", "e-mail": + emailCol = i + case "role", "rolle": + roleCol = i + } + } + if emailCol == -1 { + return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`) + } + + var out []csvMitarbeiterZeile + zeile := 1 + for { + record, err := cr.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err) + } + zeile++ + if emailCol >= len(record) { + continue + } + email := strings.TrimSpace(record[emailCol]) + if email == "" { + continue + } + role := "mitarbeiter" + if roleCol != -1 && roleCol < len(record) { + if v := strings.TrimSpace(record[roleCol]); v != "" { + role = v + } + } + out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role}) + } + return out, nil +} + +// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für +// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt +// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine +// Länge, die sich noch manuell weitergeben lässt. +func generateEinmalPasswort() (string, error) { + token, err := auth.NewSessionToken() + if err != nil { + return "", err + } + return token[:16], nil +} + +type csvImportFormData struct { + Title string + Nav navData + Error string +} + +func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) { + data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)} + if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) { + data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg} + if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type csvImportErgebnisZeile struct { + Zeile int + Email string + Role string + Passwort string // nur bei Erfolg gesetzt + Fehler string // nur bei Fehler gesetzt +} + +type csvImportErgebnisData struct { + Title string + Nav navData + Ergebnisse []csvImportErgebnisZeile +} + +// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt +// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis +// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite +// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden. +func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste + s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error()) + return + } + file, _, err := r.FormFile("csv") + if err != nil { + s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen") + return + } + defer file.Close() + + zeilen, err := parseCSVMitarbeiter(file) + if err != nil { + s.renderCSVImportForm(w, r, err.Error()) + return + } + if len(zeilen) == 0 { + s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen") + return + } + + accountID := currentUser(r).AccountID + var ergebnisse []csvImportErgebnisZeile + for _, z := range zeilen { + if !istMandantenRolle(z.Role) { + ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)}) + continue + } + passwort, err := generateEinmalPasswort() + if err != nil { + ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"}) + continue + } + hash, err := auth.HashPassword(passwort) + if err != nil { + ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()}) + continue + } + if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil { + ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"}) + continue + } + ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort}) + } + + data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse} + if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} diff --git a/internal/web/csv_import_handlers_test.go b/internal/web/csv_import_handlers_test.go new file mode 100644 index 0000000..5b3251d --- /dev/null +++ b/internal/web/csv_import_handlers_test.go @@ -0,0 +1,149 @@ +package web_test + +import ( + "bytes" + "context" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/netcell-it/deklarix/internal/web" +) + +// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch +// — reale Browser-Uploads laufen genauso über multipart/form-data. +func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := fw.Write([]byte(csvContent)); err != nil { + t.Fatalf("Write: %v", err) + } + if err := mw.Close(); err != nil { + t.Fatalf("mw.Close: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, path, &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + if cookie != nil { + req.AddCookie(cookie) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + return w +} + +func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") { + t.Fatalf("expected both imported emails in the result, got: %s", body) + } + + lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com") + if err != nil { + t.Fatalf("GetUserByEmail (lisa): %v", err) + } + if lisa.Role != "verantwortlicher" { + t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role) + } + tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com") + if err != nil { + t.Fatalf("GetUserByEmail (tom): %v", err) + } + if tom.Role != "mitarbeiter" { + t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role) + } +} + +func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + + // Das angezeigte Einmalpasswort aus der -Zelle extrahieren. + body := resp.Body.String() + start := strings.Index(body, "") + end := strings.Index(body, "") + if start == -1 || end == -1 { + t.Fatalf("expected a -Zelle mit dem Einmalpasswort, got: %s", body) + } + passwort := body[start+len("") : end] + + loginResp := postForm(t, s, nil, "/login", map[string][]string{ + "email": {"neu@example.com"}, "password": {passwort}, + }) + if loginResp.Code != http.StatusSeeOther { + t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String()) + } +} + +func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil { + t.Error("expected no user to be created for an invalid role (betreiber)") + } + if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil { + t.Error("expected the valid row to still be imported despite the earlier invalid row") + } +} + +func TestCSVImportOhneDateiZeigtFehler(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + if err := mw.Close(); err != nil { + t.Fatalf("mw.Close: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.AddCookie(adminCookie) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") { + t.Errorf("expected an error asking for a file, got: %s", w.Body.String()) + } +} + +func TestMitarbeiterCannotAccessCSVImport(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/import") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code) + } +} diff --git a/internal/web/csv_import_parse_test.go b/internal/web/csv_import_parse_test.go new file mode 100644 index 0000000..9a3d2c1 --- /dev/null +++ b/internal/web/csv_import_parse_test.go @@ -0,0 +1,64 @@ +package web + +import ( + "strings" + "testing" +) + +func TestParseCSVMitarbeiterMitRolle(t *testing.T) { + csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n" + zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv)) + if err != nil { + t.Fatalf("parseCSVMitarbeiter: %v", err) + } + if len(zeilen) != 2 { + t.Fatalf("zeilen = %+v, want 2", zeilen) + } + if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" { + t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0]) + } + if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" { + t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1]) + } +} + +func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) { + csv := "email\nlisa@example.com\n" + zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv)) + if err != nil { + t.Fatalf("parseCSVMitarbeiter: %v", err) + } + if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" { + t.Fatalf("zeilen = %+v, unerwartet", zeilen) + } +} + +func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) { + csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n" + zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv)) + if err != nil { + t.Fatalf("parseCSVMitarbeiter: %v", err) + } + if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" { + t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen) + } +} + +func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) { + csv := "name,role\nLisa,mitarbeiter\n" + _, err := parseCSVMitarbeiter(strings.NewReader(csv)) + if err == nil { + t.Fatal("expected an error when the header has no email column") + } +} + +func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) { + csv := "email\nlisa@example.com\ntom@example.com\n" + zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv)) + if err != nil { + t.Fatalf("parseCSVMitarbeiter: %v", err) + } + if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 { + t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index d2fd190..e8eeb92 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -134,6 +134,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren)) mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren)) + mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm)) + mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload)) mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung)) mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern)) mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe)) diff --git a/internal/web/templates/nutzer_import.html b/internal/web/templates/nutzer_import.html new file mode 100644 index 0000000..8854d0d --- /dev/null +++ b/internal/web/templates/nutzer_import.html @@ -0,0 +1,27 @@ +{{define "nutzer-import"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Nutzerverwaltung

+

Mitarbeiter per CSV importieren

+

+ CSV mit Kopfzeile, Pflichtspalte "email", optionale Spalte "role" + (mitarbeiter/verantwortlicher/pruefer/admin — Standard: mitarbeiter). + Es gibt keine Einladungsmail: nach dem Import wird pro Zeile ein + Einmalpasswort angezeigt, das nur DIESES EINE MAL sichtbar ist — bitte + auf dieser Seite kopieren und sicher weitergeben, bevor sie + geschlossen wird. +

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + +
+
+ + +{{end}} diff --git a/internal/web/templates/nutzer_import_ergebnis.html b/internal/web/templates/nutzer_import_ergebnis.html new file mode 100644 index 0000000..06051e1 --- /dev/null +++ b/internal/web/templates/nutzer_import_ergebnis.html @@ -0,0 +1,33 @@ +{{define "nutzer-import-ergebnis"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Import-Ergebnis

+

+ Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr + angezeigt — jetzt kopieren und sicher weitergeben. +

+
+ + + + + + {{range .Ergebnisse}} + + + + + + + {{end}} + +
ZeileE-MailRolleErgebnis
{{.Zeile}}{{.Email}}{{if .Fehler}}—{{else}}{{.Role}}{{end}}{{if .Fehler}}{{.Fehler}}{{else}}{{.Passwort}}{{end}}
+
+

Zur Nutzerverwaltung

+
+ + +{{end}} diff --git a/internal/web/templates/nutzer_liste.html b/internal/web/templates/nutzer_liste.html index e9d74d0..d2bdc8d 100644 --- a/internal/web/templates/nutzer_liste.html +++ b/internal/web/templates/nutzer_liste.html @@ -25,7 +25,7 @@ {{end}} -

Nutzer anlegen

+

Nutzer anlegen · Per CSV importieren