Compare commits

...

4 Commits

Author SHA1 Message Date
noroot
29961f5657 fix: Fragebogen-Antworten auf der Antrag-/Fall-Detailseite anzeigen
Die tatsächlich gegebenen Fragebogen-Antworten (B-Fragen zur Datenklasse,
C-Fragen zur KI-VO-Einstufung, D-Werkzeugangaben) wurden bisher nirgends
angezeigt — weder dem antragstellenden Mitarbeiter noch der Fachebene
beim Entscheiden, obwohl der Code-Kommentar an handleAntragDetail schon
"zeigt einen Antrag mit allen Antworten" versprach. Nur die daraus
abgeleitete Bewertung war sichtbar, nicht die Grundlage, auf der sie
beruht — im Audit nicht nachvollziehbar (siehe CLAUDE.md, Grundregel)
und für die Fachebene beim Entscheiden nicht praktikabel.

antwortenAnzeige() baut aus den rohen JSON-Antworten eine lesbare Liste
mit denselben Fragen-Labels wie im Fragebogen selbst — jetzt sowohl auf
GET /antraege/{id} (Ebene 2) als auch GET /faelle/{id} (Ebene 3)
sichtbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 18:11:25 +02:00
noroot
3ec57f2786 fix: admin bekommt Fachebene-Rechte (Posteingang, Entscheiden, Register, Wiedervorlage)
Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat
nur einen admin-Login und konnte damit bislang keinen einzigen
eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur
verantwortlicher/pruefer durchließ. Die Spezifikation will admin+
KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md,
Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder
zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch
dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht
in fachebene_handlers.go). pruefer bleibt unverändert nur lesend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:58:06 +02:00
noroot
6884f28109 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>
2026-08-29 17:52:02 +02:00
noroot
b91a58ccba feat: Einladungslink für die Mitarbeiter-Selbstanmeldung
Schließt den ersten Punkt der geplanten Onboarding-Reihenfolge (siehe
CLAUDE.md): ein Admin sieht unter GET /verwaltung/einladung einen
Sammellink (account.einladung_token, Migration 0013), den beliebig
viele Mitarbeitende nutzen können, um sich selbst mit eigenem Passwort
anzumelden (GET/POST /einladung/{token}, öffentlich, Rolle
mitarbeiter). Kein Ablaufdatum, aber per Admin jederzeit erneuerbar —
das macht den alten Link sofort ungültig, falls er versehentlich
außerhalb des Unternehmens geteilt wurde. Keine E-Mail-Bestätigung
(bewusst konsistent mit dem Rest des Produkts, das noch keine
E-Mail-Infrastruktur hat).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:41:36 +02:00
28 changed files with 1258 additions and 50 deletions

View File

@@ -67,7 +67,10 @@ Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
`admin` hieß.
`admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche.
@@ -325,12 +328,28 @@ leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist).
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
Registrierung.
**Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~
**erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
jederzeit erneuerbar [macht den alten Link sofort ungültig].
`GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
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~~**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
@@ -734,11 +753,20 @@ journalctl -u deklarix -f
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
entstehen, die sonst nachträglich migriert werden müssten.
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding**die
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
zwei `app_user`-Zeilen für dieselbe Person?
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~**pragmatisch
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
`fachebene_handlers.go`). Grund: ohne das könnte eine frisch
registrierte Firma mit nur einem `admin`-Login keinen einzigen
eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`

View File

@@ -10,20 +10,35 @@ import (
)
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account.
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
type Account struct {
ID string
Name string
CreatedAt time.Time
ID string
Name string
EinladungToken string
CreatedAt time.Time
}
// CreateAccount legt einen neuen Mandanten an.
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
const accountColumns = `id, name, einladung_token, created_at`
func scanAccount(row interface {
Scan(dest ...any) error
}) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
return a, err
}
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
RETURNING id, name, created_at
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
RETURNING `+accountColumns,
name,
)
a, err := scanAccount(row)
if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err)
}
@@ -32,10 +47,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
// GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
SELECT id, name, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
@@ -45,12 +58,40 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
return a, nil
}
// GetAccountByEinladungToken liest den Mandanten zu einem
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
// oder bereits erneuertem (damit ungültig gewordenem) Token.
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
}
return a, nil
}
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
if err != nil {
return fmt.Errorf("store: regenerate einladung token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, created_at FROM account ORDER BY created_at DESC
`)
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
@@ -58,8 +99,8 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
var out []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
a, err := scanAccount(rows)
if err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)

View File

@@ -25,6 +25,56 @@ func TestAccountCRUD(t *testing.T) {
if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
}
if got.EinladungToken == "" {
t.Error("expected a newly created account to have a non-empty EinladungToken")
}
}
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
if err != nil {
t.Fatalf("GetAccountByEinladungToken: %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken returned a different account")
}
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
}
}
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
alterToken := acc.EinladungToken
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
t.Fatalf("RegenerateEinladungToken: %v", err)
}
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("expected the old token to be invalid, got err = %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
if err != nil {
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
}
}
func TestUserCRUD(t *testing.T) {

View File

@@ -0,0 +1 @@
ALTER TABLE account DROP COLUMN einladung_token;

View File

@@ -0,0 +1,8 @@
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
-- dass store.CreateAccount etwas ändern muss.
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;

View File

@@ -0,0 +1,72 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
// und kann entscheiden, genau wie ein verantwortlicher.
func TestAdminSiehtPosteingang(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
}
}
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
}
}
func TestAdminKannEntscheiden(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "entschieden" {
t.Errorf("Status = %q, want entschieden", antrag.Status)
}
}
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
resp := getWithCookie(t, s, adminCookie, path)
if resp.Code != http.StatusOK {
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
}
}
}

View File

@@ -27,6 +27,115 @@ var datenfragen = []struct {
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
}
var c2FolgeLabels = map[string]string{
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
"kreditwuerdigkeit": "Kreditwürdigkeit",
"leistungsbewilligung": "Leistungsbewilligung",
"bildung": "Zugang zu Bildung",
"gesundheit": "medizinische Beurteilung",
}
var c3ArtLabels = map[string]string{
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
"social_scoring": "Social Scoring",
"biometrische_kategorisierung": "Biometrische Kategorisierung",
}
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
// siehe antwortenAnzeige.
type antwortZeile struct {
Frage string
Antwort string
}
func antwortLabel(v string) string {
switch v {
case "ja":
return "Ja"
case "nein":
return "Nein"
case "unsicher":
return "Unsicher"
default:
return v
}
}
func jaNein(v bool) string {
if v {
return "Ja"
}
return "Nein"
}
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
// Antworten, auf denen sie beruht — im Audit ist das nicht
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
var out []antwortZeile
for _, f := range datenfragen {
v, _ := antworten[f.Key].(string)
if v == "" {
v = "nein"
}
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
}
c1, _ := antworten["c1"].(bool)
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
c2, _ := antworten["c2"].(bool)
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
if c2 {
if folge, _ := antworten["c2_folge"].(string); folge != "" {
if label, ok := c2FolgeLabels[folge]; ok {
c2Zeile.Antwort += " — " + label
}
}
}
out = append(out, c2Zeile)
c3, _ := antworten["c3"].(bool)
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
if c3 {
if art, _ := antworten["c3_art"].(string); art != "" {
if label, ok := c3ArtLabels[art]; ok {
c3Zeile.Antwort += " — " + label
}
}
}
out = append(out, c3Zeile)
c4, _ := antworten["c4"].(bool)
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
c5, _ := antworten["c5"].(bool)
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
}
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
label := "Privater Zugang"
if zugang == "firma" {
label = "Firmenkonto"
}
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
}
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
label := "Privatgerät"
if geraet == "firma" {
label = "Firmengerät"
}
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
}
return out
}
type antragFormData struct {
Title string
Nav navData
@@ -285,7 +394,7 @@ type antragDetailData struct {
Haeufigkeit string
Status string
CreatedAt string
Antworten map[string]any
Antworten []antwortZeile
Bewertung *bewertungView
}
@@ -308,7 +417,7 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
data := antragDetailData{
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)

View File

@@ -75,8 +75,15 @@ func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
}
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String())
body := detailResp.Body.String()
if !strings.Contains(body, "Angebotstexte generieren") {
t.Errorf("expected the antrag title on the detail page, got: %s", body)
}
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
}
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
}
}

View File

@@ -31,8 +31,9 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
// (spätere Ausbaustufe), nicht über dieses Formular.
// Mitarbeiter über dieses Formular — die kommen über den
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
// Import/Mandantenlink (weiterhin nicht gebaut).
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})

View 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)
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -0,0 +1,140 @@
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
// Abschnitt A), nicht hier.
package web
import (
"net/http"
"github.com/netcell-it/deklarix/internal/auth"
)
type einladungFormData struct {
Title string
Firmenname string
Error string
}
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
// existiert hat oder nur nicht mehr gültig ist.
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
s.renderEinladungForm(w, acc.Name, "")
}
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderEinladungError(w http.ResponseWriter) {
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
if err := r.ParseForm(); err != nil {
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
return
}
email := r.FormValue("email")
password := r.FormValue("password")
if email == "" || password == "" {
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
s.renderEinladungForm(w, acc.Name, err.Error())
return
}
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
if err != nil {
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
type einladungVerwaltungData struct {
Title string
Nav navData
Link string
}
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
// X-Forwarded-Proto berücksichtigen.
func einladungBasisURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
// Mandanten zum Kopieren.
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := einladungVerwaltungData{
Title: "Einladungslink", Nav: navFor(r),
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
}
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
// außerhalb des Unternehmens geteilt wurde).
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
newToken, err := auth.NewSessionToken()
if err != nil {
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
}

View File

@@ -0,0 +1,144 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestEinladungFormZeigtFirmenname(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
}
}
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
}
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if len(resp.Result().Cookies()) == 0 {
t.Fatal("expected a session cookie to be set")
}
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
}
cookie := resp.Result().Cookies()[0]
protected := getWithCookie(t, s, cookie, "/antraege")
if protected.Code != http.StatusOK {
t.Fatalf("expected the new session to work, status = %d", protected.Code)
}
}
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
if len(fs.users) != 0 {
t.Error("expected no user to be created for an invalid token")
}
}
func TestAdminSiehtEinladungslink(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "/einladung/") {
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
}
}
func TestAdminKannEinladungslinkErneuern(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
altesToken := fs.accounts[admin.AccountID].EinladungToken
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
neuesToken := fs.accounts[admin.AccountID].EinladungToken
if neuesToken == altesToken {
t.Fatal("expected the token to change")
}
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
}
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
}
}
func TestMitarbeiterCannotAccessEinladungsverwaltung(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/einladung")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -5,6 +5,7 @@
package web
import (
"encoding/json"
"net/http"
"time"
@@ -71,6 +72,7 @@ type fallDetailData struct {
Haeufigkeit string
Status string
CreatedAt string
Antworten []antwortZeile
Bewertung *bewertungView
Entscheidung *entscheidungView
CanEntscheiden bool
@@ -78,6 +80,13 @@ type fallDetailData struct {
Error string
}
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
func hatEntscheidungsrecht(role string) bool {
return role == "verantwortlicher" || role == "admin"
}
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
// eine tatsächliche Entscheidung davon abweicht (dann ist die
@@ -102,10 +111,15 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
return
}
var antworten map[string]any
if len(antrag.Antworten) > 0 {
_ = json.Unmarshal(antrag.Antworten, &antworten)
}
data := fallDetailData{
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"),
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
@@ -115,7 +129,7 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
}
}
data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil
data.CanEntscheiden = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
if err == nil {
@@ -147,13 +161,14 @@ func gueltigkeitFuer(einstufung string) time.Time {
}
// handleFallEntscheiden speichert die Entscheidung eines/einer
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden.
// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
// requireFachebene (404, weil die Seite für sie gar nicht existieren
// soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall
// existiert, nur nicht entscheiden.
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user.Role != "verantwortlicher" {
if !hatEntscheidungsrecht(user.Role) {
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
return
}

View File

@@ -87,6 +87,28 @@ func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
}
}
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
func TestFallDetailZeigtFragebogenAntworten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
}
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
}
}
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)

View File

@@ -91,9 +91,16 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
// Seite tatsächlich entschieden werden darf, prüft der einzelne
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
// — ohne das könnte eine frisch registrierte Firma mit nur einem
// admin-Login keinen einzigen eingereichten Antrag sehen oder
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt.
@@ -104,7 +111,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
@@ -147,7 +154,8 @@ func navFor(r *http.Request) navData {
role := currentUser(r).Role
return navData{
IsBetreiber: role == "betreiber",
IsFachebene: role == "verantwortlicher" || role == "pruefer",
// admin sieht die Fachebene mit, siehe requireFachebene.
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
IsAdmin: role == "admin",
}
}

View File

@@ -30,6 +30,8 @@ var staticFS embed.FS
type Store interface {
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
ListAccounts(ctx context.Context) ([]store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
@@ -110,6 +112,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
@@ -130,6 +134,10 @@ 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))
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))

View File

@@ -64,11 +64,35 @@ func (f *fakeStore) newID() string {
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
id := f.newID()
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
f.accounts[acc.ID] = acc
return acc, nil
}
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, acc := range f.accounts {
if acc.EinladungToken == token {
return acc, nil
}
}
return store.Account{}, store.ErrNotFound
}
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[accountID]
if !ok {
return store.ErrNotFound
}
acc.EinladungToken = newToken
f.accounts[accountID] = acc
return nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()

View File

@@ -13,6 +13,15 @@
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Antworten}}
<h2>Antworten im Fragebogen</h2>
<ul class="findings">
{{range .Antworten}}
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
{{end}}
</ul>
{{end}}
{{if .Bewertung}}
{{template "bewertung-block" .Bewertung}}
{{else}}

View File

@@ -0,0 +1,22 @@
{{define "einladung"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Einladung annehmen</h1>
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Konto anlegen</button>
</form>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,12 @@
{{define "einladung-ungueltig"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Einladung</h1>
<p class="fehler">Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen aktuellen Link anfragen.</p>
<p><a href="/login">Zur Anmeldung</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,20 @@
{{define "einladung-verwaltung"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Einladungslink</h1>
<p class="hinweis">
Ein Sammellink für alle Mitarbeitenden — jede Person, die den Link
öffnet, kann sich selbst mit eigenem Passwort anmelden (Rolle
"mitarbeiter"). Kein Ablaufdatum, aber jederzeit erneuerbar.
</p>
<p><input type="text" readonly value="{{.Link}}" onclick="this.select()"></p>
<form method="post" action="/verwaltung/einladung/erneuern" onsubmit="return confirm('Neuen Link erzeugen? Der alte Link wird dann sofort ungültig.')">
<button type="submit" class="entfernen">Link erneuern</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -13,6 +13,15 @@
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Antworten}}
<h2>Antworten im Fragebogen</h2>
<ul class="findings">
{{range .Antworten}}
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
{{end}}
</ul>
{{end}}
{{if .Bewertung}}
{{template "bewertung-block" .Bewertung}}
{{else}}

View File

@@ -28,6 +28,7 @@
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}

View 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">&larr; 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}}

View 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}}

View File

@@ -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>