Schließt den zweiten Punkt der geplanten Onboarding-Reihenfolge. Admin lädt eine CSV mit Spalten email (Pflicht) und role (optional, Standard mitarbeiter) unter GET /verwaltung/nutzer/import hoch. Wie beim einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird ein Einmalpasswort erzeugt und nur auf der Ergebnisseite direkt nach dem Import angezeigt, danach nirgendwo mehr abrufbar. Kein Alles-oder-nichts-Import: eine ungültige Zeile blockiert nicht die übrigen, jede Zeile bekommt ihr eigenes Ergebnis. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
6.0 KiB
Go
185 lines
6.0 KiB
Go
// 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)
|
|
}
|
|
}
|