feat: CSV-Import für Mitarbeiter
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>
This commit is contained in:
184
internal/web/csv_import_handlers.go
Normal file
184
internal/web/csv_import_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
149
internal/web/csv_import_handlers_test.go
Normal file
149
internal/web/csv_import_handlers_test.go
Normal file
@@ -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 <code>-Zelle extrahieren.
|
||||
body := resp.Body.String()
|
||||
start := strings.Index(body, "<code>")
|
||||
end := strings.Index(body, "</code>")
|
||||
if start == -1 || end == -1 {
|
||||
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
|
||||
}
|
||||
passwort := body[start+len("<code>") : 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)
|
||||
}
|
||||
}
|
||||
64
internal/web/csv_import_parse_test.go
Normal file
64
internal/web/csv_import_parse_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
27
internal/web/templates/nutzer_import.html
Normal file
27
internal/web/templates/nutzer_import.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "nutzer-import"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/verwaltung/nutzer">← Nutzerverwaltung</a></p>
|
||||
<h1>Mitarbeiter per CSV importieren</h1>
|
||||
<p class="hinweis">
|
||||
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.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
|
||||
<label for="csv">CSV-Datei</label>
|
||||
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
|
||||
|
||||
<button type="submit">Importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "nutzer-import-ergebnis"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Import-Ergebnis</h1>
|
||||
<p class="fehler">
|
||||
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
|
||||
angezeigt — jetzt kopieren und sicher weitergeben.
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Zeile</th><th>E-Mail</th><th>Rolle</th><th>Ergebnis</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Ergebnisse}}
|
||||
<tr>
|
||||
<td>{{.Zeile}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{if .Fehler}}—{{else}}{{.Role}}{{end}}</td>
|
||||
<td>{{if .Fehler}}<span class="fehler">{{.Fehler}}</span>{{else}}<code>{{.Passwort}}</code>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p><a href="/verwaltung/nutzer">Zur Nutzerverwaltung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -25,7 +25,7 @@
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a> · <a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user