Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7397f70068 | ||
|
|
2736e2c0db | ||
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 |
108
CLAUDE.md
108
CLAUDE.md
@@ -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
|
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
|
||||||
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
||||||
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
|
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
|
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||||
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||||
@@ -112,12 +115,41 @@ reiner Anzeige-Screen reicht nicht):
|
|||||||
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
||||||
erreichbar (404).
|
erreichbar (404).
|
||||||
|
|
||||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
|
**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
|
||||||
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
|
`GET /betreiber/accounts/{id}` kann sich der Betreiber über
|
||||||
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
|
`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
|
||||||
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
|
anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
|
||||||
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
|
`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
|
||||||
wurde beim Produktwechsel sogar entfernt).
|
0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt
|
||||||
|
die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum
|
||||||
|
Betreiber" — der Betreiber meldet sich danach mit den eigenen
|
||||||
|
Zugangsdaten neu an). Jede Nutzung erzeugt einen `audit_log`-Eintrag
|
||||||
|
(Action `betreiber_login_als_nutzer`); während der gesamten Sitzung
|
||||||
|
zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
|
||||||
|
siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist,
|
||||||
|
im Kontext eines fremden Kontos zu handeln.
|
||||||
|
|
||||||
|
**Firmen-CRUD für den Betreiber ist umgesetzt:** `GET/POST
|
||||||
|
/betreiber/accounts/neu` legt eine Firma samt erstem `admin`-Login an
|
||||||
|
(dasselbe Ergebnis wie die öffentliche Registrierung, nur vom Betreiber
|
||||||
|
ausgelöst — z. B. für vertriebsunterstütztes Onboarding oder Testkonten),
|
||||||
|
`POST /betreiber/accounts/{id}/umbenennen` korrigiert den Firmennamen
|
||||||
|
(`store.UpdateAccount`). Jede Firmenanlage erzeugt einen
|
||||||
|
`audit_log`-Eintrag. Bewusst **kein** Löschen — ein Hard-Delete würde
|
||||||
|
gegen die Fremdschlüssel aus `antrag`/`app_user`/`audit_log` laufen und
|
||||||
|
Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
|
||||||
|
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
|
||||||
|
noch nicht, siehe unten).
|
||||||
|
|
||||||
|
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes
|
||||||
|
Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am
|
||||||
|
2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme
|
||||||
|
10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die
|
||||||
|
eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus-
|
||||||
|
Zugangsdaten), Sperren/Deaktivieren eines Accounts durch den Betreiber
|
||||||
|
(hängt weiter an der Abrechnungs-/Freischaltungs-Architektur, siehe
|
||||||
|
Offene Punkte: `account.verified` wurde beim Produktwechsel sogar
|
||||||
|
entfernt).
|
||||||
|
|
||||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||||
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||||
@@ -334,10 +366,19 @@ jederzeit erneuerbar [macht den alten Link sofort ungültig].
|
|||||||
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
||||||
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
||||||
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
||||||
Einladung.), (2) CSV-Import, (3) SSO (Entra ID/Google Workspace),
|
Einladung.), (2) ~~CSV-Import~~ — **erledigt** (`internal/web/csv_import_handlers.go`,
|
||||||
(4) Antrag ganz ohne vorheriges Konto über Mandantenlink + E-Mail-
|
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
|
||||||
Bestätigungscode (Konto entsteht nebenbei). (2)-(4) sind weiterhin
|
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
|
||||||
nicht gebaut.
|
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**
|
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
|
||||||
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
||||||
@@ -722,16 +763,24 @@ journalctl -u deklarix -f
|
|||||||
|
|
||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
- ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
|
||||||
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
|
2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
|
||||||
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
|
nötig.
|
||||||
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
|
- ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
|
||||||
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
|
(`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
|
||||||
bleiben, aber die Ableitung kann sich noch ändern.
|
eingeschlossen.
|
||||||
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
||||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
|
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
|
||||||
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
|
die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
|
||||||
(siehe `rules/OPEN.md`, Punkt 3).
|
ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
|
||||||
|
- ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
|
||||||
|
— **bestätigt**, `intern` bleibt die konservative Standardannahme.
|
||||||
|
- **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
|
||||||
|
(`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
|
||||||
|
`kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
|
||||||
|
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
||||||
|
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
||||||
|
Rückmeldung dazu bisher eingeholt.
|
||||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||||
@@ -741,11 +790,20 @@ journalctl -u deklarix -f
|
|||||||
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
||||||
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
||||||
entstehen, die sonst nachträglich migriert werden müssten.
|
entstehen, die sonst nachträglich migriert werden müssten.
|
||||||
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die
|
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||||
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
|
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||||
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
|
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||||
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
|
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
|
||||||
zwei `app_user`-Zeilen für dieselbe Person?
|
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
|
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
|
||||||
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
||||||
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||||
|
// Betreiber-gestützten Anlage korrigieren).
|
||||||
|
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: update account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||||
|
|||||||
@@ -30,6 +30,39 @@ func TestAccountCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetAccountByEinladungToken(t *testing.T) {
|
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -191,6 +224,9 @@ func TestSessionCRUD(t *testing.T) {
|
|||||||
if got.UserID != user.ID {
|
if got.UserID != user.ID {
|
||||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||||
}
|
}
|
||||||
|
if got.ImpersonatedByUserID != nil {
|
||||||
|
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||||
t.Fatalf("DeleteSession: %v", err)
|
t.Fatalf("DeleteSession: %v", err)
|
||||||
@@ -207,3 +243,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateImpersonatedSession(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
kundenAcc := testAccountID(t, s)
|
||||||
|
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||||
|
}
|
||||||
|
betreiberAcc := testAccountID(t, s)
|
||||||
|
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||||
|
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||||
|
}
|
||||||
|
if sess.UserID != kundenNutzer.ID {
|
||||||
|
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||||
|
}
|
||||||
|
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSession(ctx, sess.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||||
|
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||||
|
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||||
|
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||||
|
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||||
|
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||||
|
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||||
|
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||||
@@ -12,38 +12,70 @@ import (
|
|||||||
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||||
|
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||||
|
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||||
|
// statt durch den regulären Login des Nutzers selbst.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string
|
Token string
|
||||||
UserID string
|
UserID string
|
||||||
ExpiresAt time.Time
|
ImpersonatedByUserID *string
|
||||||
CreatedAt time.Time
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||||
|
|
||||||
|
func scanSession(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Session, error) {
|
||||||
|
var sess Session
|
||||||
|
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||||
|
return sess, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
var sess Session
|
row := s.Pool.QueryRow(ctx, `
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO session (token, user_id, expires_at)
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING token, user_id, expires_at, created_at
|
RETURNING `+sessionColumns,
|
||||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
token, userID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||||
}
|
}
|
||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||||
|
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||||
|
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||||
|
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||||
|
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||||
|
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+sessionColumns,
|
||||||
|
token, userID, impersonatedByUserID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
var sess Session
|
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
sess, err := scanSession(row)
|
||||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
|
||||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,115 @@ var datenfragen = []struct {
|
|||||||
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
{"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 {
|
type antragFormData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
@@ -251,7 +360,6 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type anforderungView struct {
|
type anforderungView struct {
|
||||||
ID string
|
|
||||||
Beschreibung string
|
Beschreibung string
|
||||||
Herleitung string
|
Herleitung string
|
||||||
}
|
}
|
||||||
@@ -278,14 +386,13 @@ type bewertungView struct {
|
|||||||
type antragDetailData struct {
|
type antragDetailData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
ID string
|
|
||||||
Titel string
|
Titel string
|
||||||
Beschreibung string
|
Beschreibung string
|
||||||
Ergebnis string
|
Ergebnis string
|
||||||
Haeufigkeit string
|
Haeufigkeit string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
Antworten map[string]any
|
Antworten []antwortZeile
|
||||||
Bewertung *bewertungView
|
Bewertung *bewertungView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,9 +413,9 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := antragDetailData{
|
data := antragDetailData{
|
||||||
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
|
||||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
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)
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
@@ -333,7 +440,7 @@ func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewert
|
|||||||
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
}
|
}
|
||||||
for _, a := range b.Anforderungen {
|
for _, a := range b.Anforderungen {
|
||||||
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
}
|
}
|
||||||
for _, id := range b.ZulaessigeWerkzeuge {
|
for _, id := range b.ZulaessigeWerkzeuge {
|
||||||
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||||
|
|||||||
@@ -75,8 +75,15 @@ func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
|||||||
if detailResp.Code != http.StatusOK {
|
if detailResp.Code != http.StatusOK {
|
||||||
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") {
|
body := detailResp.Body.String()
|
||||||
t.Errorf("expected the antrag title on the detail page, got: %s", 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||||
|
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||||
|
// (Support-Login).
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
@@ -149,5 +157,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
98
internal/web/betreiber_account_crud_test.go
Normal file
98
internal/web/betreiber_account_crud_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
|
||||||
|
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if user.Role != "admin" {
|
||||||
|
t.Errorf("Role = %q, want admin", user.Role)
|
||||||
|
}
|
||||||
|
acc, err := fs.GetAccount(context.Background(), user.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if acc.Name != "Neue Firma GmbH" {
|
||||||
|
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der neue Login funktioniert tatsächlich.
|
||||||
|
loginResp := postForm(t, s, nil, "/login", url.Values{
|
||||||
|
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if loginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Pflicht") {
|
||||||
|
t.Errorf("expected a validation error, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Alter Name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := fs.GetAccount(context.Background(), acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name" {
|
||||||
|
t.Errorf("Name = %q, want Neuer Name", got.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "Neuer Name") {
|
||||||
|
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type betreiberDashboardData struct {
|
type betreiberDashboardData struct {
|
||||||
@@ -72,9 +75,103 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type betreiberAccountNeuData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
|
||||||
|
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
|
||||||
|
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
|
||||||
|
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
|
||||||
|
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
|
||||||
|
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
|
||||||
|
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountName := r.FormValue("account_name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if accountName == "" || email == "" || password == "" {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
|
||||||
|
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
|
||||||
|
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
|
||||||
|
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
|
||||||
|
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
|
||||||
|
// audit_log laufen).
|
||||||
|
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
|
||||||
|
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
type betreiberUserView struct {
|
type betreiberUserView struct {
|
||||||
Email string
|
ID string
|
||||||
Role string
|
Email string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type betreiberAccountDetailData struct {
|
type betreiberAccountDetailData struct {
|
||||||
@@ -105,7 +202,7 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
|
|||||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
}
|
}
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
@@ -144,3 +241,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request)
|
|||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||||
|
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||||
|
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||||
|
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||||
|
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||||
|
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||||
|
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||||
|
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||||
|
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||||
|
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||||
|
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||||
|
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||||
|
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||||
|
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||||
|
betreiber := currentUser(r)
|
||||||
|
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||||
|
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ziel.Active {
|
||||||
|
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||||
|
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ type fallDetailData struct {
|
|||||||
Haeufigkeit string
|
Haeufigkeit string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
|
Antworten []antwortZeile
|
||||||
Bewertung *bewertungView
|
Bewertung *bewertungView
|
||||||
Entscheidung *entscheidungView
|
Entscheidung *entscheidungView
|
||||||
CanEntscheiden bool
|
CanEntscheiden bool
|
||||||
@@ -78,6 +80,13 @@ type fallDetailData struct {
|
|||||||
Error string
|
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
|
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
|
||||||
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
||||||
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
||||||
@@ -102,10 +111,15 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var antworten map[string]any
|
||||||
|
if len(antrag.Antworten) > 0 {
|
||||||
|
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||||
|
}
|
||||||
|
|
||||||
data := fallDetailData{
|
data := fallDetailData{
|
||||||
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
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)
|
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.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)
|
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -147,13 +161,14 @@ func gueltigkeitFuer(einstufung string) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
||||||
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein
|
// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
|
||||||
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die
|
// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
|
||||||
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein
|
// requireFachebene (404, weil die Seite für sie gar nicht existieren
|
||||||
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden.
|
// 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) {
|
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
if user.Role != "verantwortlicher" {
|
if !hatEntscheidungsrecht(user.Role) {
|
||||||
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
|
|||||||
@@ -11,15 +11,49 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type indexData struct {
|
type indexData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
|
OffeneAntraege int
|
||||||
|
ZeigePosteingang bool
|
||||||
|
OffenerPosteingang int
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
|
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
|
||||||
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
|
// Überblick statt reiner Navigations-Duplikate (die Links stehen
|
||||||
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
|
// eh schon in der Seitenleiste): eigene offene Anträge, und für die
|
||||||
|
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
|
||||||
|
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
|
||||||
|
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
|
||||||
|
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
|
||||||
|
// bei jedem Login geladen wird, zu teuer.
|
||||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
data := indexData{Title: "Start", Nav: navFor(r)}
|
user := currentUser(r)
|
||||||
|
nav := navFor(r)
|
||||||
|
data := indexData{Title: "Start", Nav: nav}
|
||||||
|
|
||||||
|
if !nav.IsBetreiber {
|
||||||
|
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, a := range eigene {
|
||||||
|
if a.Status == "eingereicht" {
|
||||||
|
data.OffeneAntraege++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nav.IsFachebene {
|
||||||
|
data.ZeigePosteingang = true
|
||||||
|
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
|
||||||
|
if err == nil {
|
||||||
|
for _, a := range alle {
|
||||||
|
if a.Status == "eingereicht" {
|
||||||
|
data.OffenerPosteingang++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
63
internal/web/handlers_test.go
Normal file
63
internal/web/handlers_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `href="/antraege">Meine offenen Anträge <span class="status">1</span>`) {
|
||||||
|
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `href="/faelle">Posteingang <span class="status">1</span>`) {
|
||||||
|
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexZeigtKeinenPosteingangFuerMitarbeiter(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Posteingang") {
|
||||||
|
t.Errorf("expected no Posteingang tile for mitarbeiter, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `href="/betreiber">Zur Plattform`) {
|
||||||
|
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||||
|
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
120
internal/web/impersonation_test.go
Normal file
120
internal/web/impersonation_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("expected a new session cookie")
|
||||||
|
}
|
||||||
|
neueCookie := cookies[0]
|
||||||
|
|
||||||
|
// Die neue Sitzung ist tatsächlich der Kunden-Login.
|
||||||
|
protected := getWithCookie(t, s, neueCookie, "/antraege")
|
||||||
|
if protected.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
neueCookie := loginResp.Result().Cookies()[0]
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, neueCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
|
||||||
|
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
|
||||||
|
var gefunden bool
|
||||||
|
for _, e := range fs.auditLog {
|
||||||
|
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
|
||||||
|
gefunden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotLoginAls(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,44 +12,67 @@ const sessionCookieName = "deklarix_session"
|
|||||||
|
|
||||||
type contextKey int
|
type contextKey int
|
||||||
|
|
||||||
const userContextKey contextKey = iota
|
const (
|
||||||
|
userContextKey contextKey = iota
|
||||||
|
impersonatorContextKey
|
||||||
|
)
|
||||||
|
|
||||||
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
|
||||||
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||||
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
|
||||||
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
|
||||||
|
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
|
||||||
|
// sonst leer.
|
||||||
|
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
if time.Now().After(sess.ExpiresAt) {
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||||
if err != nil || !user.Active {
|
if err != nil || !user.Active {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
return user, true
|
|
||||||
|
impersonator := ""
|
||||||
|
if sess.ImpersonatedByUserID != nil {
|
||||||
|
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
|
||||||
|
impersonator = betreiber.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, impersonator, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
|
||||||
|
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
|
||||||
|
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
if impersonator != "" {
|
||||||
|
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||||
|
}
|
||||||
|
return r.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +82,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||||
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +99,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||||
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -85,30 +108,37 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||||
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||||
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
|
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
|
||||||
// Seite tatsächlich entschieden werden darf, prüft der einzelne
|
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
|
||||||
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
|
// 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
|
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
|
||||||
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
||||||
// nicht erfährt, dass es die Seite überhaupt gibt.
|
// nicht erfährt, dass es die Seite überhaupt gibt.
|
||||||
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
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)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +147,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -126,7 +156,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +165,10 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||||
// Plattform-Link gezeigt werden soll.
|
// Plattform-Link gezeigt werden soll.
|
||||||
type navData struct {
|
type navData struct {
|
||||||
IsBetreiber bool
|
IsBetreiber bool
|
||||||
IsFachebene bool
|
IsFachebene bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
|
||||||
}
|
}
|
||||||
|
|
||||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||||
@@ -147,8 +178,10 @@ func navFor(r *http.Request) navData {
|
|||||||
role := currentUser(r).Role
|
role := currentUser(r).Role
|
||||||
return navData{
|
return navData{
|
||||||
IsBetreiber: role == "betreiber",
|
IsBetreiber: role == "betreiber",
|
||||||
IsFachebene: role == "verantwortlicher" || role == "pruefer",
|
// admin sieht die Fachebene mit, siehe requireFachebene.
|
||||||
IsAdmin: role == "admin",
|
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||||
|
IsAdmin: role == "admin",
|
||||||
|
Impersonator: currentImpersonator(r),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,3 +196,10 @@ func currentUser(r *http.Request) store.User {
|
|||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentImpersonator liefert die E-Mail des Betreibers, falls die
|
||||||
|
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
|
||||||
|
func currentImpersonator(r *http.Request) string {
|
||||||
|
v, _ := r.Context().Value(impersonatorContextKey).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ var staticFS embed.FS
|
|||||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
|
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||||
@@ -39,6 +40,7 @@ type Store interface {
|
|||||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||||
SetUserActive(ctx context.Context, id string, active bool) error
|
SetUserActive(ctx context.Context, id string, active bool) error
|
||||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||||
|
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||||
DeleteSession(ctx context.Context, token string) error
|
DeleteSession(ctx context.Context, token string) error
|
||||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
@@ -134,6 +136,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
|
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}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
|
||||||
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
|
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("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||||
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
||||||
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
||||||
@@ -149,7 +153,11 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
|
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
|
||||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
||||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||||
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||||
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
|
|||||||
return acc, nil
|
return acc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
acc.Name = name
|
||||||
|
f.accounts[id] = acc
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -180,6 +192,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
|
|||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||||
|
f.sessions[token] = sess
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|||||||
@@ -275,6 +275,38 @@ nav button {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
|
||||||
|
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
|
||||||
|
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
|
||||||
|
.impersonation-banner {
|
||||||
|
background: var(--color-mittel-bg);
|
||||||
|
color: var(--color-mittel);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-mittel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.impersonation-banner {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fehler {
|
.fehler {
|
||||||
background: var(--color-hoch-bg);
|
background: var(--color-hoch-bg);
|
||||||
color: var(--color-hoch);
|
color: var(--color-hoch);
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</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}}
|
{{if .Bewertung}}
|
||||||
{{template "bewertung-block" .Bewertung}}
|
{{template "bewertung-block" .Bewertung}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -8,11 +8,25 @@
|
|||||||
<h1>{{.Name}}</h1>
|
<h1>{{.Name}}</h1>
|
||||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||||
|
|
||||||
|
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
|
||||||
|
<label for="name">Firmenname</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||||
|
<button type="submit">Umbenennen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<h2>Nutzer</h2>
|
<h2>Nutzer</h2>
|
||||||
<ul class="beteiligte">
|
<ul class="beteiligte">
|
||||||
{{range .Users}}
|
{{range .Users}}
|
||||||
<li class="beteiligter">
|
<li class="beteiligter">
|
||||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
<div class="beteiligter-kopf">
|
||||||
|
{{.Email}} <span class="rolle">({{.Role}})</span>
|
||||||
|
{{if not .Active}}<span class="status">deaktiviert</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Active}}
|
||||||
|
<form method="post" action="/betreiber/accounts/{{$.AccountID}}/nutzer/{{.ID}}/anmelden-als" onsubmit="return confirm('Als {{.Email}} anmelden? Ihre eigene Sitzung wird dabei ersetzt.')">
|
||||||
|
<button type="submit">Als Nutzer anmelden</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
29
internal/web/templates/betreiber_account_neu.html
Normal file
29
internal/web/templates/betreiber_account_neu.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "betreiber-account-neu"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/betreiber/accounts">← Accounts</a></p>
|
||||||
|
<h1>Firma anlegen</h1>
|
||||||
|
<p class="hinweis">
|
||||||
|
Legt eine Firma mit dem ersten Login (Rolle admin) an — dasselbe
|
||||||
|
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
|
||||||
|
</p>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/betreiber/accounts">
|
||||||
|
<label for="account_name">Firmenname</label>
|
||||||
|
<input type="text" id="account_name" name="account_name" required>
|
||||||
|
|
||||||
|
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
|
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
||||||
|
<input type="password" id="password" name="password" minlength="8" required>
|
||||||
|
|
||||||
|
<button type="submit">Firma anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</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}}
|
{{if .Bewertung}}
|
||||||
{{template "bewertung-block" .Bewertung}}
|
{{template "bewertung-block" .Bewertung}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -5,10 +5,19 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Willkommen bei Deklarix</h1>
|
<h1>Willkommen bei Deklarix</h1>
|
||||||
|
{{if .Nav.IsBetreiber}}
|
||||||
|
<ul class="admin-kacheln">
|
||||||
|
<li><a href="/betreiber">Zur Plattform</a></li>
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
<ul class="admin-kacheln">
|
<ul class="admin-kacheln">
|
||||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||||
<li><a href="/antraege">Meine Anträge</a></li>
|
<li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</span></a></li>
|
||||||
|
{{if .ZeigePosteingang}}
|
||||||
|
<li><a href="/faelle">Posteingang <span class="status">{{.OffenerPosteingang}}</span></a></li>
|
||||||
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -37,6 +37,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
{{if .Impersonator}}
|
||||||
|
<div class="impersonation-banner">
|
||||||
|
Support-Zugriff durch <strong>{{.Impersonator}}</strong> — dies ist nicht Ihre eigene Sitzung.
|
||||||
|
<form method="post" action="/logout" style="display:inline">
|
||||||
|
<button type="submit">Sitzung beenden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||||
|
|||||||
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>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ bewusst nicht geraten, sondern hier notiert — vor Phase 3 (Ableitungen
|
|||||||
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
|
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
|
||||||
betrieblichen Datenschutzbeauftragten) klären.
|
betrieblichen Datenschutzbeauftragten) klären.
|
||||||
|
|
||||||
## 1. Rangfolge der Datenklassen
|
## 1. Rangfolge der Datenklassen — BESTÄTIGT (2026-08-29)
|
||||||
|
|
||||||
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
|
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
|
||||||
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
|
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt diese
|
||||||
diese Reihenfolge an (niedrigster zu höchstem Rang):
|
Reihenfolge an (niedrigster zu höchstem Rang) — **vom Produktverantwortlichen
|
||||||
|
bestätigt, keine Änderung nötig:**
|
||||||
|
|
||||||
1. `oeffentlich`
|
1. `oeffentlich`
|
||||||
2. `intern`
|
2. `intern`
|
||||||
@@ -18,22 +19,22 @@ diese Reihenfolge an (niedrigster zu höchstem Rang):
|
|||||||
5. `berufsgeheimnis`
|
5. `berufsgeheimnis`
|
||||||
6. `besondere_kategorie`
|
6. `besondere_kategorie`
|
||||||
|
|
||||||
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
|
Begründung: DSGVO Art. 9 (besondere Kategorien) gilt allgemein als
|
||||||
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
|
striktester Datenschutz-Tatbestand, § 203 StGB (Berufsgeheimnis) hat
|
||||||
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
|
eigene strafrechtliche Relevanz — beide vor "normalen" personenbezogenen
|
||||||
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
|
Daten eingeordnet. `auftragsdaten` unter `personenbezogen` einsortiert,
|
||||||
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
|
weil vertragliche Geheimhaltung in der Regel schwächer sanktioniert ist
|
||||||
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
|
als DSGVO-Bußgelder.
|
||||||
bestätigt.**
|
|
||||||
|
|
||||||
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
|
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten` — BESTÄTIGT (2026-08-29)
|
||||||
|
|
||||||
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
|
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
|
||||||
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
||||||
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
|
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
|
||||||
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
|
`rules/anforderungen.yaml` schließt `auftragsdaten` ein (Kundendaten
|
||||||
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
sollten aus denselben Gründen wie Geschäftsgeheimnisse nicht zum
|
||||||
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
|
Training verwendet werden) — **vom Produktverantwortlichen bestätigt,
|
||||||
|
keine Änderung nötig.**
|
||||||
|
|
||||||
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
||||||
|
|
||||||
@@ -43,9 +44,9 @@ jetzt eine Folgefrage `c3_art` mit den Werten
|
|||||||
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||||
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
||||||
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
||||||
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
|
final, nicht mehr Platzhalter. Ob diese drei Varianten fachlich
|
||||||
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
|
vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
|
||||||
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
|
**bestätigt (keine Ergänzung nötig).**
|
||||||
|
|
||||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||||
|
|
||||||
@@ -60,15 +61,15 @@ wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
|||||||
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
||||||
muss die Filterfunktion entsprechend erweitert werden.
|
muss die Filterfunktion entsprechend erweitert werden.
|
||||||
|
|
||||||
## 5. Fallback, wenn keine Datenklasse zutrifft
|
## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
|
||||||
|
|
||||||
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
||||||
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
||||||
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
||||||
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
||||||
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
||||||
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
|
Zweifel mehr Schutzanforderungen, nicht weniger. **Vom
|
||||||
bestätigt**, nur eine sichere Standardannahme.
|
Produktverantwortlichen bestätigt, keine Änderung nötig.**
|
||||||
|
|
||||||
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user