Compare commits

...

14 Commits

Author SHA1 Message Date
noroot
d77b19f61f feat: Zweck-Vokabular auf 12 Kategorien erweitert, Werkzeug-Formular im Enterprise-Look überarbeitet
Sechs neue Zweck-Kategorien (Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten)
ergänzen die ursprünglichen sechs — deckt jetzt die tatsächliche
Bandbreite an Einsatzgebieten ab statt nur klassischer Wissensarbeit.

Werkzeug-Formular (Betreiber + Mandant, gleiche Vorlage) neu strukturiert:
Karten-Layout mit thematischen Abschnitten, 2-Spalten-Grid für kurze
Felder, Chip-Gruppen statt langer Checkbox-Listen, abgesetzte
Gefahrenzone für den Löschen-Button. Dabei einen vorbestehenden Bug
gefunden und behoben: das Lösch-Formular war fest auf die
Betreiber-Route verdrahtet, obwohl dieselbe Vorlage auch vom
Mandanten-Formular genutzt wird.
2026-08-31 13:12:07 +02:00
noroot
dbeaa45644 feat: Ampel-Kennzeichnung für Prüfalter im Werkzeugkatalog, Scroll-Fade für breite Tabellen
Katalogtabellen zeigten bisher nur "nicht aktuell geprüft" als
Binärflag; jetzt eine dreistufige Ampel (aktuell/bald fällig/
abgelaufen) mit denselben Farbtoken wie die KI-VO-Risikoampel.
Zusätzlich: .table-scroll bekommt einen rechten Fade-Rand, damit eine
am Fensterrand abgeschnittene breite Tabelle als "hier geht's mit
Scrollen weiter" erkennbar ist statt wie ein Rendering-Fehler
auszusehen.
2026-08-31 12:28:57 +02:00
noroot
4358dbaa0d feat: Werkzeugkatalog als Tabelle, Aufbewahrung nullable, kontrolliertes Zweck-Vokabular, Subprozessoren
Katalog (zentral + mandantenseitig) zeigt jetzt eine mehrspaltige
Tabelle statt einer Liste. aufbewahrung_tage ist nullable (NULL =
vom Anbieter nicht beziffert, unterscheidbar von echter 0-Tage-
Zusicherung). geeignete_zwecke ist Checkbox-Auswahl aus den sechs
Fragebogen-Zweck-Kategorien statt Freitext. Neues Feld
subprozessoren macht Unterauftragsverarbeiter durchsuchbar statt nur
Freitext in Einschränkungen. 180-Tage-Frische-Markierung in beiden
Katalogansichten ergänzt.
2026-08-31 12:08:05 +02:00
noroot
7397f70068 feat: Firmen-CRUD für den Betreiber (anlegen, umbenennen)
Schließt eine echte Lücke: der Betreiber-Bereich konnte Accounts
bisher nur lesend anzeigen, eine neue Firma entstand ausschließlich
über die öffentliche Selbstregistrierung. GET/POST
/betreiber/accounts/neu legt jetzt eine Firma samt erstem admin-Login
direkt vom Betreiber aus an (z. B. für vertriebsunterstütztes
Onboarding oder Testkonten) — erzeugt einen audit_log-Eintrag. POST
/betreiber/accounts/{id}/umbenennen korrigiert den Firmennamen
(store.UpdateAccount). 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 Prinzip wie bei Nutzern (deaktivieren
statt löschen), ein Sperren/Deaktivieren für Accounts fehlt aber noch
und hängt an der noch nicht getroffenen Abrechnungsarchitektur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:39:01 +02:00
noroot
2736e2c0db feat: Support-Login — Betreiber kann sich als Kunden-Nutzer anmelden
Ebene 5 (Betreiber) kann sich jetzt auf der Account-Detailseite über
einen Button je Nutzer als dieser Kunden-Login anmelden, ohne dessen
Passwort zu kennen — für Support-Fälle, in denen der Betreiber
nachvollziehen muss, was ein Kunde sieht. Nicht für deaktivierte
Nutzer möglich. Neue Spalte session.impersonated_by_user_id (Migration
0014, store.CreateImpersonatedSession) hält fest, wer die Sitzung
ausgelöst hat — die Nav zeigt während der gesamten Sitzung einen
auffälligen Banner ("Support-Zugriff durch ..."), damit nie unklar
ist, im Kontext eines fremden Kontos zu handeln. Jede Nutzung erzeugt
einen audit_log-Eintrag. Die neue Sitzung ersetzt die eigene
Betreiber-Sitzung (kein Sitzungs-Stack) — nach der Nutzung meldet sich
der Betreiber mit den eigenen Zugangsdaten neu an.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 00:38:40 +02:00
noroot
9d2cb79424 fix: Startseite zeigt echte Übersicht statt Phase-1-Platzhalter
handleIndex war seit dem Produktwechsel unverändert ein Platzhalter aus
Schritt 1 ("Antrag stellen"/"Meine Anträge" — beides bereits in der
Seitenleiste vorhanden, die Startseite selbst zeigte nichts Eigenes).
Zeigt jetzt die Zahl eigener offener Anträge, und für die Fachebene
zusätzlich die Zahl offener Fälle im Posteingang; Betreiber sehen
einen Link zur Plattform statt leerer Antrags-Kacheln, die für sie
ohnehin nicht gelten. Bewusst ohne Wiedervorlage-Zahl — deren
Berechnung vergleicht jeden Genehmigungs-Snapshot gegen den aktuellen
Katalogstand und wäre für eine bei jedem Login geladene Startseite zu
teuer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:10:42 +02:00
noroot
4dd9bd88cb docs: fachliche Annahmen im Regelwerk bestätigt
Rangfolge der Datenklassen, auftragsdaten bei kein_training_auf_eingabe,
Vollständigkeit der drei "verboten"-Varianten und der intern-Fallback
wurden vom Produktverantwortlichen bestätigt (rules/OPEN.md). Weiterhin
offen: konkrete Löschfristen in Tagen, harte Filterung von
menschliche_aufsicht/kennzeichnungspflicht/dsfa_erforderlich.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:47:07 +02:00
noroot
96ac5d6b59 refactor: unbenutzte View-Felder entfernen (ID auf drei Templates nie referenziert)
Ein systematischer Audit nach demselben Muster wie beim Fragebogen-
Antworten-Bug fand drei Felder, die vom Handler befüllt, aber im
zugehörigen Template nie referenziert werden: antragDetailData.ID
(kein Formular auf der Seite braucht sie), anforderungView.ID (die
Beschreibung reicht als lesbare Anforderung, die interne Regel-ID ist
kein Nutzerinhalt), betreiberAccountDetailData.AccountID (die
Account-Detailseite ist rein lesend, kein Formular zeigt darauf).
Anders als der Fragebogen-Antworten-Bug ist hier keine für Nutzer
relevante Information verloren gegangen — die Felder waren schlicht
totes Gewicht, deshalb entfernt statt eine Anzeige dafür zu erfinden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 21:07:00 +02:00
noroot
29961f5657 fix: Fragebogen-Antworten auf der Antrag-/Fall-Detailseite anzeigen
Die tatsächlich gegebenen Fragebogen-Antworten (B-Fragen zur Datenklasse,
C-Fragen zur KI-VO-Einstufung, D-Werkzeugangaben) wurden bisher nirgends
angezeigt — weder dem antragstellenden Mitarbeiter noch der Fachebene
beim Entscheiden, obwohl der Code-Kommentar an handleAntragDetail schon
"zeigt einen Antrag mit allen Antworten" versprach. Nur die daraus
abgeleitete Bewertung war sichtbar, nicht die Grundlage, auf der sie
beruht — im Audit nicht nachvollziehbar (siehe CLAUDE.md, Grundregel)
und für die Fachebene beim Entscheiden nicht praktikabel.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:58:06 +02:00
noroot
6884f28109 feat: CSV-Import für Mitarbeiter
Schließt den zweiten Punkt der geplanten Onboarding-Reihenfolge. Admin
lädt eine CSV mit Spalten email (Pflicht) und role (optional, Standard
mitarbeiter) unter GET /verwaltung/nutzer/import hoch. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt und nur auf der Ergebnisseite direkt nach
dem Import angezeigt, danach nirgendwo mehr abrufbar. Kein
Alles-oder-nichts-Import: eine ungültige Zeile blockiert nicht die
übrigen, jede Zeile bekommt ihr eigenes Ergebnis.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:41:36 +02:00
noroot
eb99891e38 feat: eigene Werkzeugkatalog-Einträge für Mandanten
Ein Mandant kann jetzt zusätzlich zum Sperren zentraler Katalogeinträge
auch eigene, nur für sich sichtbare Werkzeuge anlegen/bearbeiten/
löschen (GET/POST /verwaltung/werkzeuge/neu bzw. /{id}, POST
/{id}/loeschen — werkzeug.account_id = eigener Account). Nutzt
dasselbe Formular wie der zentrale Katalog des Betreibers
(werkzeugFormData/betreiber-werkzeug-form.html, ein neues ActionBase-
Feld unterscheidet die Ziel-URL); ein zentraler oder fremder Eintrag
bleibt über diese Route unerreichbar (404). Schließt die letzte
dokumentierte Lücke bei "eigene Werkzeug-Freigaben/-Sperrungen" (Ebene 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:23:43 +02:00
noroot
36af1bf288 feat: responsives Frontend — Seitenleiste ab Desktop, Tabellen-Styling
Nav wird jetzt für alle Seiten aus einer einzigen Stelle
(layout.html-Block) gerendert: auf dem Handy ein per Checkbox-Hack
aufklappbares Menü (kein JavaScript), ab 960px Breite per CSS in eine
permanente, dunkle Seitenleiste im enconf-Look verwandelt — dieselbe
Markup-Struktur bedient beide Layouts, kein separates Desktop-Template.
<details>/<summary> wurde bewusst NICHT verwendet: aktuelle
Chromium-Versionen steuern dessen Auf-/Zuklapp-Zustand über eine
CSS-resistente interne Animation (auch mit !important nicht
überschreibbar), per Headless-Screenshot verifiziert.

Zusätzlich: Tabellen (vorher komplett ungestylt, betraf v. a. das
Register) haben jetzt echte Gestaltung inkl. horizontal scrollbarem
Wrapper für schmale Bildschirme, Listenkarten ohne Link-Wrapper
(Nutzer-/Abteilungsliste) bekommen dieselbe Kartenoptik wie
verlinkte Einträge, und destruktive Aktionen (Löschen/Deaktivieren)
sind jetzt visuell von primären Aktionen unterschieden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:06:14 +02:00
54 changed files with 3349 additions and 300 deletions

292
CLAUDE.md
View File

@@ -39,8 +39,8 @@ Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
erkennen, siehe `gueltigeZwecke` weiter unten), nie für die Einstufung
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
---
@@ -67,7 +67,10 @@ Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
`admin` hieß.
`admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche.
@@ -98,20 +101,61 @@ reiner Anzeige-Screen reicht nicht):
reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese
Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und
unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit.
- **Eigene Werkzeug-Sperrungen** (`internal/web/mandant_werkzeug_handlers.go`,
- **Eigene Werkzeug-Sperrungen und -Einträge** (`internal/web/mandant_werkzeug_handlers.go`,
`GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen
Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne
den zentralen Katalog selbst zu verändern — das bleibt Sache der
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`).
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). Zusätzlich kann
ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/
bearbeiten/löschen (`GET/POST /verwaltung/werkzeuge/neu`, `GET/POST
/verwaltung/werkzeuge/{id}`, `POST /verwaltung/werkzeuge/{id}/loeschen`
`werkzeug.account_id` = der eigene Account). Nutzt dasselbe Formular
wie der zentrale Katalog (`werkzeugFormData`/`betreiber-werkzeug-form.html`,
ein `ActionBase`-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL)
— ein zentraler oder fremder Eintrag ist über diese Route nicht
erreichbar (404).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, eigene
Werkzeug-EINTRÄGE eines Mandanten (nur Sperrungen zentraler Einträge
sind umgesetzt, `account_id`-gesetzte eigene Katalogeinträge fehlen
noch), Rechnungsdaten/Abrechnung (kein Abo-System, bewusst "Nicht bauen
v1"), Account-Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts
nur lesend an — Bearbeiten/Sperren hängt an der noch nicht getroffenen
Abrechnungs-/Freischaltungs-Architektur, siehe Offene Punkte:
`account.verified` wurde beim Produktwechsel sogar entfernt).
**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
`GET /betreiber/accounts/{id}` kann sich der Betreiber über
`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
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, das
Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche
Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes,
externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**.
Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte
Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum),
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst
(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage
Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der
Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts
durch den Betreiber hängt weiter an dieser (jetzt kleineren)
Subskriptions-Architektur (siehe Offene Punkte: `account.verified`
wurde beim Produktwechsel sogar entfernt).
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
@@ -209,9 +253,66 @@ zentralen Katalog zu verändern.
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
nicht in der UI umgesetzt, siehe Offene Punkte).
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
`betreiber_werkzeug_handlers.go`, CSS-Klassen `status-niedrig`/
`-mittel`/`-hoch`, dieselben Farbtoken wie die KI-VO-Risikoampel):
**aktuell** (grün, < 150 Tage), **bald fällig** (gelb, 150180 Tage —
dasselbe 30-Tage-Vorwarnfenster wie bei der Wiedervorlage), **abgelaufen**
(rot, > 180 Tage).
`aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet
"vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte
Zusicherung: sofortige Löschung", z. B. DeepL) unterschieden. Vorher
war die Spalte `NOT NULL DEFAULT 0`, wodurch beide Fälle identisch
aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
"0 Tage".
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
Vokabular statt Freitext: das Formular zeigt Checkboxen statt eines
Freitextfelds (`gueltigeZwecke` in `betreiber_werkzeug_handlers.go`,
serverseitig validiert via `istGueltigerZweck`). Grund: ein künftiger
automatischer Zweckabgleich (Bewertungslogik Schritt 3, weiterhin nicht
gebaut) würde an inkonsistentem Freitext ("Text" statt
"Textgenerierung") scheitern. Am 2026-08-31 von sechs auf zwölf
Kategorien erweitert, nachdem der Nutzer zurecht einwandte, dass sechs
Kategorien die tatsächliche Bandbreite an Einsatzgebieten nicht
abdecken: Textgenerierung, Übersetzung, Code, Bild, Transkription,
Recherche, **Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten**.
Es gibt keinen einheitlichen Branchenstandard für KI-Funktions-
kategorien (regulatorische Taxonomien wie die KI-VO selbst oder OECD/
NIST klassifizieren nach Risiko, nicht nach Funktion; Marktplatz-
Kategorien wie bei G2 sind Marketing, kein Standard) — diese zwölf
sind Deklarix' eigene, bewusst begrenzte Liste für den künftigen
Zweckabgleich, keine allgemeine KI-Taxonomie.
Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
AWS bei DeepL) als eigenes, durchsuchbares Feld sichtbar statt nur als
Freitext in `einschraenkungen` — das ist oft der Grund, warum ein
scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
als Tabelle (Name, Anbieter, Verarbeitungsort, AVV, Training aus,
Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
Der zentrale Katalog ist aktuell mit 10 recherchierten Einträgen befüllt
(ChatGPT Enterprise, Microsoft 365 Copilot, Claude for Work, GitHub
Copilot Business, DeepL Pro/API, Google Gemini für Google Workspace,
Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly Enterprise,
Otter.ai Business) — deckt damit erstmals alle sechs Zweck-Kategorien
ab (vorher fehlten Bild und Transkription vollständig, Recherche hatte
kein dediziertes Werkzeug). Jede Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
zwei Einträge (Adobe Firefly: Trainingsausschluss ist nicht Standard;
Otter.ai: Training auf de-identifizierten Aufnahmen, laufende
Rechtsstreitigkeiten) sind bewusst mit kritischen Einschränkungen
versehen statt schöngefärbt, das ist die vorgesehene Funktion des
Katalogs.
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
@@ -228,8 +329,8 @@ bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
und gegen `geeignete_zwecke` filtern.
(die zwölf Kategorien aus `gueltigeZwecke`, siehe Werkzeugkatalog
weiter unten) und gegen `geeignete_zwecke` filtern.
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
Prüfung.
@@ -319,12 +420,28 @@ leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist).
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
Registrierung.
**Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~
**erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
jederzeit erneuerbar [macht den alten Link sofort ungültig].
`GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
Einladung.), (2) ~~CSV-Import~~**erledigt** (`internal/web/csv_import_handlers.go`,
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`],
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
@@ -332,6 +449,83 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
---
## Frontend
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
buchbarer Service werden soll): **für Browser UND Mobile optimiert,
und jede Entität im Datenmodell muss über das Frontend steuerbar sein**
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
Stack bleibt `html/template` + htmx, kein React/Vue/SPA (siehe
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
**Navigation** (`internal/web/templates/layout.html`, `"nav"`-Block,
einmal definiert und von jeder Seite eingebunden — Änderungen hier
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
`<label for=...>` als Klick-Ziel) klappt auf dem Handy ein Menü auf,
ganz ohne JavaScript. **Nicht** das naheliegendere `<details>`/`<summary>`
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
Zustand über eine interne, mit CSS (auch nicht mit `!important`)
nicht überschreibbare Animation; ein Versuch, `.app-nav { display:
flex !important }` bei geschlossenem `<details>` zu erzwingen, bleibt
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
960px Breite (`@media (min-width: 960px)`, `app.css`) wird exakt
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
Seitenleiste (Farb-Tokens aus `design/enterprise.css` übernommen) —
kein separates Desktop-Template.
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
Scrollen auf dem Handy statt gequetschter Spalten). Seit 2026-08-31 hat
`.table-scroll` zusätzlich einen rechten Fade-Rand (`mask-image`) —
ohne den sah eine am Fensterrand abgeschnittene breite Tabelle wie ein
Rendering-Fehler aus statt wie "hier geht's mit Scrollen weiter" (per
Screenshot am Werkzeugkatalog beobachtet: die letzten zwei Spalten
waren nur als einzelne Buchstaben sichtbar, ganz ohne Hinweis auf mehr
Inhalt).
**Formulare** (`.form-card`/`.form-section`/`.form-grid`/`.chip-group`
in `app.css`, seit 2026-08-31, erstmals angewendet auf
`betreiber_werkzeug_form.html`): vorher war jedes Formular eine flache
Spalte aus `<label>`+`<input>` ohne Gruppierung — bei einem Formular
mit über einem Dutzend Feldern (Werkzeug anlegen) wirkte das "klobig,
einfach nur untereinander" (O-Ton Nutzer). Jetzt: `.form-card` (weiße
Karte mit Rahmen/Schatten, Ant-Design-Tokens aus `design/enterprise.css`
übernommen) enthält mehrere `<fieldset class="form-section">`
(thematische Abschnitte mit Titel, per `border-top` getrennt), darin
`.form-grid` (2 Spalten ab 640px für kurze Felder wie Name/Anbieter
nebeneinander, `.form-full` erzwingt volle Breite für lange Felder).
Checkbox-/Radio-Gruppen mit vielen Optionen (Verarbeitungsort, die
zwölf Zwecke) sind `.chip-group` — abgerundete Pillen statt einer
langen Untereinander-Liste, ausgewählte Chips werden über
`:has(input:checked)` blau hervorgehoben (kein JavaScript). Ein
Löschen-Button steht nicht mehr einfach unter dem Formular, sondern in
einer abgesetzten `.form-gefahrenzone`-Fußzeile der Karte. Dieselben
Klassen sind bewusst generisch (nicht an ein Formular gebunden) — noch
nicht auf andere Formulare (Login/Registrierung, Fragebogen, übrige
Admin-Formulare) angewendet, das wäre der nächste Schritt, falls
gewünscht. Beim Umbau auffällig gewordener, vorbestehender Bug: das
Lösch-Formular in `betreiber_werkzeug_form.html` war fest auf
`/betreiber/werkzeuge/{id}/loeschen` verdrahtet, obwohl dieselbe
Vorlage auch vom Mandanten-Formular (`/verwaltung/werkzeuge/...`)
genutzt wird — ein Mandant hätte beim Löschen eines eigenen Werkzeugs
403 bekommen. Jetzt `{{.ActionBase}}/{{.ID}}/loeschen`, wie die
Formular-`action` selbst.
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
authentifiziert abrufen, `/static/...`-Pfade im HTML auf eine absolute
`http://localhost:<port>/static/...`-URL umschreiben (Assets brauchen
kein Auth-Cookie), dann `chromium --headless --disable-gpu --no-sandbox
--window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html`
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
screenshotten. Für einen Menü-Aufklapp-Zustand: `checked`-Attribut
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
---
## Projektstruktur
```
@@ -346,7 +540,10 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
│ └── web/ # Handler, Templates
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
│ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
│ (Farben/Radius/Schatten), nicht direkt eingebunden
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
├── packaging/
│ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template
@@ -663,16 +860,24 @@ journalctl -u deklarix -f
## Offene Punkte
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
bleiben, aber die Ableitung kann sich noch ändern.
- ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
nötig.
- ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
(`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
eingeschlossen.
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
(siehe `rules/OPEN.md`, Punkt 3).
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
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
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
@@ -682,11 +887,20 @@ journalctl -u deklarix -f
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
entstehen, die sonst nachträglich migriert werden müssten.
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding**die
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
zwei `app_user`-Zeilen für dieselbe Person?
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~**pragmatisch
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
`fachebene_handlers.go`). Grund: ohne das könnte eine frisch
registrierte Firma mit nur einem `admin`-Login keinen einzigen
eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`

View File

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

View File

@@ -25,6 +25,89 @@ func TestAccountCRUD(t *testing.T) {
if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
}
if got.EinladungToken == "" {
t.Error("expected a newly created account to have a non-empty EinladungToken")
}
}
func 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) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
if err != nil {
t.Fatalf("GetAccountByEinladungToken: %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken returned a different account")
}
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
}
}
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
alterToken := acc.EinladungToken
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
t.Fatalf("RegenerateEinladungToken: %v", err)
}
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("expected the old token to be invalid, got err = %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
if err != nil {
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
}
}
func TestUserCRUD(t *testing.T) {
@@ -141,6 +224,9 @@ func TestSessionCRUD(t *testing.T) {
if 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 {
t.Fatalf("DeleteSession: %v", err)
@@ -157,3 +243,38 @@ func TestGetSessionNotFound(t *testing.T) {
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)
}
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE session DROP COLUMN impersonated_by_user_id;

View File

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

View File

@@ -0,0 +1,5 @@
ALTER TABLE werkzeug DROP COLUMN subprozessoren;
UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL;

View File

@@ -0,0 +1,12 @@
-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig:
-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung),
-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das
-- sauber: "unbekannt" statt "angeblich sofort gelöscht".
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT;
-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei
-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung
-- eines Werkzeugs materiell — bisher nur als Freitext in
-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld.
ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}';

View File

@@ -12,38 +12,70 @@ import (
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
// 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 {
Token string
UserID string
ExpiresAt time.Time
CreatedAt time.Time
Token string
UserID string
ImpersonatedByUserID *string
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
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
row := s.Pool.QueryRow(ctx, `
INSERT INTO session (token, user_id, expires_at)
VALUES ($1, $2, $3)
RETURNING token, user_id, expires_at, created_at
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
RETURNING `+sessionColumns,
token, userID, expiresAt,
)
sess, err := scanSession(row)
if err != nil {
return Session{}, fmt.Errorf("store: create session: %w", err)
}
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
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
sess, err := scanSession(row)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}

View File

@@ -25,8 +25,9 @@ type Werkzeug struct {
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
@@ -36,7 +37,7 @@ type Werkzeug struct {
}
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
// Create/Update — bei 14 Feldern lesbarer als eine positionale
// Create/Update — bei 15 Feldern lesbarer als eine positionale
// Parameterliste.
type WerkzeugInput struct {
AccountID *string
@@ -47,8 +48,9 @@ type WerkzeugInput struct {
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
AufbewahrungTage *int
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
@@ -56,8 +58,8 @@ type WerkzeugInput struct {
}
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
func scanWerkzeug(row interface {
Scan(dest ...any) error
@@ -65,8 +67,8 @@ func scanWerkzeug(row interface {
var w Werkzeug
err := row.Scan(
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
)
return w, err
}
@@ -86,6 +88,9 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
if in.Einschraenkungen == nil {
in.Einschraenkungen = []string{}
}
if in.Subprozessoren == nil {
in.Subprozessoren = []string{}
}
}
// CreateWerkzeug legt einen Katalogeintrag an.
@@ -94,12 +99,12 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
row := s.Pool.QueryRow(ctx, `
INSERT INTO werkzeug (
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING `+werkzeugColumns,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
@@ -120,12 +125,12 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
UPDATE werkzeug SET
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
letzte_pruefung = $13, quelle = $14, updated_at = now()
zertifizierungen = $10, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13,
letzte_pruefung = $14, quelle = $15, updated_at = now()
WHERE id = $1
RETURNING `+werkzeugColumns,
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)

View File

@@ -9,6 +9,8 @@ import (
"github.com/netcell-it/deklarix/internal/store"
)
func intPtr(v int) *int { return &v }
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
return store.WerkzeugInput{
AccountID: accountID,
@@ -19,8 +21,9 @@ func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
AVVURL: "https://beispiel.example/avv",
TrainingOptOut: true,
TrainingStandard: true,
AufbewahrungTage: 30,
AufbewahrungTage: intPtr(30),
Zertifizierungen: []string{"ISO 27001"},
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
GeeigneteZwecke: []string{"Textgenerierung"},
Einschraenkungen: nil,
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
@@ -42,6 +45,24 @@ func TestWerkzeugCRUD(t *testing.T) {
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
}
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
}
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
}
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
in.AufbewahrungTage = nil
return in
}())
if err != nil {
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
}
if unbekannt.AufbewahrungTage != nil {
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
}
got, err := s.GetWerkzeug(ctx, w.ID)
if err != nil {
@@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) {
}
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
updateInput.AufbewahrungTage = 14
updateInput.AufbewahrungTage = intPtr(14)
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
if err != nil {
t.Fatalf("UpdateWerkzeug: %v", err)
}
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage == nil || *updated.AufbewahrungTage != 14 {
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
}

View File

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

View File

@@ -27,6 +27,115 @@ var datenfragen = []struct {
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
}
var c2FolgeLabels = map[string]string{
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
"kreditwuerdigkeit": "Kreditwürdigkeit",
"leistungsbewilligung": "Leistungsbewilligung",
"bildung": "Zugang zu Bildung",
"gesundheit": "medizinische Beurteilung",
}
var c3ArtLabels = map[string]string{
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
"social_scoring": "Social Scoring",
"biometrische_kategorisierung": "Biometrische Kategorisierung",
}
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
// siehe antwortenAnzeige.
type antwortZeile struct {
Frage string
Antwort string
}
func antwortLabel(v string) string {
switch v {
case "ja":
return "Ja"
case "nein":
return "Nein"
case "unsicher":
return "Unsicher"
default:
return v
}
}
func jaNein(v bool) string {
if v {
return "Ja"
}
return "Nein"
}
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
// Antworten, auf denen sie beruht — im Audit ist das nicht
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
var out []antwortZeile
for _, f := range datenfragen {
v, _ := antworten[f.Key].(string)
if v == "" {
v = "nein"
}
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
}
c1, _ := antworten["c1"].(bool)
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
c2, _ := antworten["c2"].(bool)
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
if c2 {
if folge, _ := antworten["c2_folge"].(string); folge != "" {
if label, ok := c2FolgeLabels[folge]; ok {
c2Zeile.Antwort += " — " + label
}
}
}
out = append(out, c2Zeile)
c3, _ := antworten["c3"].(bool)
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
if c3 {
if art, _ := antworten["c3_art"].(string); art != "" {
if label, ok := c3ArtLabels[art]; ok {
c3Zeile.Antwort += " — " + label
}
}
}
out = append(out, c3Zeile)
c4, _ := antworten["c4"].(bool)
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
c5, _ := antworten["c5"].(bool)
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
}
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
label := "Privater Zugang"
if zugang == "firma" {
label = "Firmenkonto"
}
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
}
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
label := "Privatgerät"
if geraet == "firma" {
label = "Firmengerät"
}
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
}
return out
}
type antragFormData struct {
Title string
Nav navData
@@ -180,9 +289,13 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
}
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
for i, w := range werkzeuge {
aufbewahrungTage := 0
if w.AufbewahrungTage != nil {
aufbewahrungTage = *w.AufbewahrungTage
}
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
}}
}
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
@@ -251,7 +364,6 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
}
type anforderungView struct {
ID string
Beschreibung string
Herleitung string
}
@@ -278,14 +390,13 @@ type bewertungView struct {
type antragDetailData struct {
Title string
Nav navData
ID string
Titel string
Beschreibung string
Ergebnis string
Haeufigkeit string
Status string
CreatedAt string
Antworten map[string]any
Antworten []antwortZeile
Bewertung *bewertungView
}
@@ -306,9 +417,9 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
}
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,
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)
@@ -333,7 +444,7 @@ func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewert
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
}
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 {
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))

View File

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

View File

@@ -31,8 +31,9 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
// (spätere Ausbaustufe), nicht über dieses Formular.
// Mitarbeiter über dieses Formular — die kommen über den
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
// Import/Mandantenlink (weiterhin nicht gebaut).
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
@@ -139,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 {
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{
Name: sessionCookieName,
Value: token,
@@ -148,5 +157,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
SameSite: http.SameSiteStrictMode,
Expires: expiresAt,
})
return nil
}

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

View File

@@ -2,6 +2,9 @@ package web
import (
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/auth"
)
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 {
Email string
Role string
ID string
Email string
Role string
Active bool
}
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"),
}
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 {
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)
}
}
// 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)
}

View File

@@ -25,6 +25,28 @@ func istGueltigerVerarbeitungsort(v string) bool {
return false
}
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
// (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
// Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
// künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
// 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
// v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
// — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
var gueltigeZwecke = []string{
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
}
func istGueltigerZweck(v string) bool {
for _, z := range gueltigeZwecke {
if z == v {
return true
}
}
return false
}
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
// führende/folgende Leerzeichen und ohne leere Einträge.
@@ -44,7 +66,27 @@ type werkzeugListItem struct {
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
Zertifizierungen string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS string
}
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
// sofortigen Löschung) verwechselt wird.
func formatAufbewahrung(tage *int) string {
if tage == nil {
return "unbekannt"
}
if *tage == 0 {
return "0 Tage (sofort)"
}
return strconv.Itoa(*tage) + " Tage"
}
type werkzeugListeData struct {
@@ -53,6 +95,34 @@ type werkzeugListeData struct {
Werkzeuge []werkzeugListItem
}
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
// nicht erst wenn die Frist schon gerissen ist.
const (
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
)
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
func pruefStatus(letztePruefung time.Time) (label, css string) {
alter := time.Since(letztePruefung)
switch {
case alter > werkzeugPruefungVeraltetNach:
return "abgelaufen", "status-hoch"
case alter > werkzeugPruefungBaldFaelligNach:
return "bald fällig", "status-mittel"
default:
return "aktuell", "status-niedrig"
}
}
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
@@ -62,9 +132,14 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
}
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
for _, wz := range werkzeuge {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
@@ -72,9 +147,14 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
}
}
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
type werkzeugFormData struct {
Title string
Nav navData
ActionBase string
ID string // leer bei "neu"
Name string
Anbieter string
@@ -84,26 +164,61 @@ type werkzeugFormData struct {
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
Zertifizierungen string
GeeigneteZwecke string
Subprozessoren string
ZweckeOptionen []string
ZweckeAusgewaehlt map[string]bool
Einschraenkungen string
LetztePruefung string
Quelle string
Error string
}
// zweckeAusgewaehltVon baut die Checkbox-Vorbelegung aus einer bereits
// gespeicherten oder erneut eingereichten Liste von Zwecken.
func zweckeAusgewaehltVon(ausgewaehlt []string) map[string]bool {
m := make(map[string]bool, len(ausgewaehlt))
for _, z := range ausgewaehlt {
m[z] = true
}
return m
}
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
aufbewahrung := ""
if wz.AufbewahrungTage != nil {
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
}
return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(wz.GeeigneteZwecke),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
}
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), Verarbeitungsorte: gueltigeVerarbeitungsorte}
data := werkzeugFormData{
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
// AccountID ist immer nil — diese Seiten pflegen ausschließlich den
// zentralen Katalog.
func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
// für einen mandantenspezifischen Eintrag (Ebene 4).
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
if err != nil {
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
@@ -118,10 +233,24 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
if name == "" || anbieter == "" || quelle == "" {
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
}
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
var aufbewahrungTage *int
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
wert, err := strconv.Atoi(rohwert)
if err != nil || wert < 0 {
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
}
aufbewahrungTage = &wert
}
var geeigneteZwecke []string
for _, z := range r.Form["geeignete_zwecke"] {
if istGueltigerZweck(z) {
geeigneteZwecke = append(geeigneteZwecke, z)
}
}
return store.WerkzeugInput{
AccountID: nil,
AccountID: accountID,
Name: name,
Anbieter: anbieter,
Verarbeitungsort: verarbeitungsort,
@@ -131,7 +260,8 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: aufbewahrungTage,
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
GeeigneteZwecke: geeigneteZwecke,
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
LetztePruefung: letztePruefung,
Quelle: quelle,
@@ -141,13 +271,15 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
// statt das Formular zu leeren.
func werkzeugFormDataFromRequest(r *http.Request, title, id, errMsg string) werkzeugFormData {
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
return werkzeugFormData{
Title: title, Nav: navFor(r), ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"),
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(r.Form["geeignete_zwecke"]),
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
Quelle: r.FormValue("quelle"), Error: errMsg,
}
@@ -159,16 +291,16 @@ func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Re
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in, errMsg := werkzeugInputFromForm(r)
in, errMsg := werkzeugInputFromForm(r, nil)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", errMsg)
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -187,15 +319,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
data := werkzeugFormData{
Title: "Werkzeug bearbeiten", Nav: navFor(r), ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -216,16 +340,16 @@ func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Re
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in, errMsg := werkzeugInputFromForm(r)
in, errMsg := werkzeugInputFromForm(r, nil)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, errMsg)
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

View File

@@ -0,0 +1,184 @@
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
// beim einzelnen "Nutzer anlegen" generiert der Import ein
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
package web
import (
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/auth"
)
type csvMitarbeiterZeile struct {
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
Email string
Role string
}
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
// einzelne falsche Rolle nicht den ganzen Import verhindert).
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
}
emailCol, roleCol := -1, -1
for i, h := range header {
switch strings.ToLower(strings.TrimSpace(h)) {
case "email", "e-mail":
emailCol = i
case "role", "rolle":
roleCol = i
}
}
if emailCol == -1 {
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
}
var out []csvMitarbeiterZeile
zeile := 1
for {
record, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
}
zeile++
if emailCol >= len(record) {
continue
}
email := strings.TrimSpace(record[emailCol])
if email == "" {
continue
}
role := "mitarbeiter"
if roleCol != -1 && roleCol < len(record) {
if v := strings.TrimSpace(record[roleCol]); v != "" {
role = v
}
}
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
}
return out, nil
}
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
// Länge, die sich noch manuell weitergeben lässt.
func generateEinmalPasswort() (string, error) {
token, err := auth.NewSessionToken()
if err != nil {
return "", err
}
return token[:16], nil
}
type csvImportFormData struct {
Title string
Nav navData
Error string
}
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type csvImportErgebnisZeile struct {
Zeile int
Email string
Role string
Passwort string // nur bei Erfolg gesetzt
Fehler string // nur bei Fehler gesetzt
}
type csvImportErgebnisData struct {
Title string
Nav navData
Ergebnisse []csvImportErgebnisZeile
}
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
return
}
file, _, err := r.FormFile("csv")
if err != nil {
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
return
}
defer file.Close()
zeilen, err := parseCSVMitarbeiter(file)
if err != nil {
s.renderCSVImportForm(w, r, err.Error())
return
}
if len(zeilen) == 0 {
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
return
}
accountID := currentUser(r).AccountID
var ergebnisse []csvImportErgebnisZeile
for _, z := range zeilen {
if !istMandantenRolle(z.Role) {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
continue
}
passwort, err := generateEinmalPasswort()
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
continue
}
hash, err := auth.HashPassword(passwort)
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
continue
}
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
continue
}
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
}
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,149 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/web"
)
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
// — reale Browser-Uploads laufen genauso über multipart/form-data.
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := fw.Write([]byte(csvContent)); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
t.Fatalf("expected both imported emails in the result, got: %s", body)
}
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (lisa): %v", err)
}
if lisa.Role != "verantwortlicher" {
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
}
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (tom): %v", err)
}
if tom.Role != "mitarbeiter" {
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
}
}
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
body := resp.Body.String()
start := strings.Index(body, "<code>")
end := strings.Index(body, "</code>")
if start == -1 || end == -1 {
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
}
passwort := body[start+len("<code>") : end]
loginResp := postForm(t, s, nil, "/login", map[string][]string{
"email": {"neu@example.com"}, "password": {passwort},
})
if loginResp.Code != http.StatusSeeOther {
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
}
}
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
t.Error("expected no user to be created for an invalid role (betreiber)")
}
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
t.Error("expected the valid row to still be imported despite the earlier invalid row")
}
}
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(adminCookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
}
}
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -0,0 +1,64 @@
package web
import (
"strings"
"testing"
)
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 {
t.Fatalf("zeilen = %+v, want 2", zeilen)
}
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
}
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
}
}
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
csv := "email\nlisa@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
}
}
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
}
}
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
csv := "name,role\nLisa,mitarbeiter\n"
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err == nil {
t.Fatal("expected an error when the header has no email column")
}
}
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
csv := "email\nlisa@example.com\ntom@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,15 +11,49 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}
type indexData struct {
Title string
Nav navData
Title string
Nav navData
OffeneAntraege int
ZeigePosteingang bool
OffenerPosteingang int
}
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
// Überblick statt reiner Navigations-Duplikate (die Links stehen
// 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) {
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 {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

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

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

View File

@@ -0,0 +1,103 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge", werkzeugForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
}
var id string
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
for wid, w := range fs.werkzeuge {
if w.AccountID != nil && *w.AccountID == admin.AccountID {
id = wid
}
}
if id == "" {
t.Fatal("expected an eigenes werkzeug to exist")
}
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
}
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
}
updateForm := werkzeugForm()
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
if updateResp.Code != http.StatusSeeOther {
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
}
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
}
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusSeeOther {
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
}
if _, ok := fs.werkzeuge[id]; ok {
t.Error("expected the eigenes werkzeug to be deleted")
}
}
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
}
}
func TestAdminCannotEditFremdesEigenesWerkzeug(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
postForm(t, s, adminA, "/verwaltung/werkzeuge", werkzeugForm())
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
var id string
for wid, w := range fs.werkzeuge {
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
id = wid
}
}
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
}
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusNotFound {
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
}
}

View File

@@ -1,26 +1,52 @@
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go).
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go),
// UND eigene, mandantenspezifische Katalogeinträge pflegen
// (`werkzeug.account_id` = der eigene Account) — dieselbe
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
package web
import "net/http"
import (
"net/http"
"strings"
)
type mandantWerkzeugItem struct {
ID string
Name string
Anbieter string
Gesperrt bool
ID string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS string
Gesperrt bool
}
type eigenesWerkzeugItem struct {
ID string
Name string
Anbieter string
Verarbeitungsort string
LetztePruefung string
}
type mandantWerkzeugListeData struct {
Title string
Nav navData
Werkzeuge []mandantWerkzeugItem
Title string
Nav navData
Werkzeuge []mandantWerkzeugItem
EigeneWerkzeuge []eigenesWerkzeugItem
}
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag.
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
// Löschen.
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
@@ -33,6 +59,11 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Werkzeuge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
gesperrt := make(map[string]bool, len(sperren))
for _, sp := range sperren {
gesperrt[sp.WerkzeugID] = true
@@ -40,8 +71,24 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
for _, wz := range zentral {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID],
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
Gesperrt: gesperrt[wz.ID],
})
}
for _, wz := range eigene {
if wz.AccountID == nil {
continue // zentral, oben schon gelistet
}
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
})
}
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
@@ -49,6 +96,108 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
}
}
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
func (s *Server) handleMandantWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := currentUser(r).AccountID
in, errMsg := werkzeugInputFromForm(r, &accountID)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
// anderen Mandanten.
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
accountID := currentUser(r).AccountID
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
// Katalogeintrag.
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
accountID := currentUser(r).AccountID
wz, err := s.store.GetWerkzeug(r.Context(), id)
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in, errMsg := werkzeugInputFromForm(r, &accountID)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
accountID := currentUser(r).AccountID
wz, err := s.store.GetWerkzeug(r.Context(), id)
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für

View File

@@ -12,44 +12,67 @@ const sessionCookieName = "deklarix_session"
type contextKey int
const userContextKey contextKey = iota
const (
userContextKey contextKey = iota
impersonatorContextKey
)
// 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
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
// 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)
if err != nil || cookie.Value == "" {
return store.User{}, false
return store.User{}, "", false
}
sess, err := s.store.GetSession(r.Context(), cookie.Value)
if err != nil {
return store.User{}, false
return store.User{}, "", false
}
if time.Now().After(sess.ExpiresAt) {
return store.User{}, false
return store.User{}, "", false
}
user, err := s.store.GetUser(r.Context(), sess.UserID)
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
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
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.
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
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.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -85,30 +108,37 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
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
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
// Seite tatsächlich entschieden werden darf, prüft der einzelne
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
// — ohne das könnte eine frisch registrierte Firma mit nur einem
// admin-Login keinen einzigen eingereichten Antrag sehen oder
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
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.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -126,7 +156,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
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
// Plattform-Link gezeigt werden soll.
type navData struct {
IsBetreiber bool
IsFachebene bool
IsAdmin bool
IsBetreiber bool
IsFachebene 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.
@@ -147,8 +178,10 @@ func navFor(r *http.Request) navData {
role := currentUser(r).Role
return navData{
IsBetreiber: role == "betreiber",
IsFachebene: role == "verantwortlicher" || role == "pruefer",
IsAdmin: role == "admin",
// admin sieht die Fachebene mit, siehe requireFachebene.
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
}
// 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
}

View File

@@ -29,7 +29,10 @@ var staticFS embed.FS
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
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)
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
ListAccounts(ctx context.Context) ([]store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
@@ -37,6 +40,7 @@ type Store interface {
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
SetUserActive(ctx context.Context, id string, active bool) 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)
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
@@ -110,6 +114,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
@@ -130,15 +136,28 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
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("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/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))

View File

@@ -64,11 +64,47 @@ func (f *fakeStore) newID() string {
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
id := f.newID()
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
f.accounts[acc.ID] = acc
return acc, nil
}
func (f *fakeStore) 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) {
f.mu.Lock()
defer f.mu.Unlock()
for _, acc := range f.accounts {
if acc.EinladungToken == token {
return acc, nil
}
}
return store.Account{}, store.ErrNotFound
}
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[accountID]
if !ok {
return store.ErrNotFound
}
acc.EinladungToken = newToken
f.accounts[accountID] = acc
return nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -156,6 +192,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
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) {
f.mu.Lock()
defer f.mu.Unlock()

View File

@@ -1,9 +1,10 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
erweitert für größere Bildschirme. */
Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
@import url('/static/inter.css');
@@ -14,6 +15,7 @@
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--color-bg: #f8fafc;
--color-text: #334155;
@@ -27,6 +29,12 @@
--color-mittel-bg: #fffbeb;
--color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4;
--sidebar-width: 220px;
--sidebar-bg: linear-gradient(180deg, #0b1426 0%, #101d33 50%, #0d1829 100%);
--sidebar-text: #b6c2d9;
--sidebar-text-hover: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
}
* {
@@ -74,23 +82,109 @@ a {
margin: 0 auto;
}
nav {
/* ── Navigation ──────────────────────────────────────────────────────
Mobil: .topbar ist eine schmale, fixierte Kopfleiste mit einem
aufklappbaren <details>-Menü (siehe layout.html) — kein JavaScript,
das <details>-Element liefert Auf-/Zuklappen nativ. Ab Desktop-Breite
(siehe Media Query unten) wird dieselbe Markup-Struktur per CSS in
eine permanent sichtbare, dunkle Seitenleiste verwandelt. */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--color-border);
}
nav a {
color: var(--color-muted);
.brand {
font-weight: 600;
color: var(--color-heading);
text-decoration: none;
letter-spacing: -0.01em;
}
/* Checkbox-Hack: eine unsichtbare Checkbox plus ein <label for=...>
als Klick-/Tap-Ziel steuern per CSS-Geschwister-Selektor, ob .app-nav
sichtbar ist — funktioniert ohne JavaScript und ohne die Falle von
<details> (siehe Kommentar in layout.html). */
.nav-toggle {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.nav-toggle-label {
cursor: pointer;
padding: 8px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.nav-toggle:focus-visible ~ .nav-toggle-label {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
}
.nav-toggle:checked ~ .nav-toggle-label {
border-color: var(--branding-primary);
color: var(--branding-primary);
}
.app-nav {
display: none;
position: absolute;
top: calc(100% + 6px);
right: 16px;
z-index: 20;
flex-direction: column;
gap: 2px;
min-width: 220px;
padding: 8px;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.nav-toggle:checked ~ .app-nav {
display: flex;
}
.app-nav a {
padding: 10px 12px;
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
}
nav a:hover {
color: var(--color-text);
.app-nav a:hover {
background: var(--color-bg);
color: var(--branding-primary);
}
.app-nav form {
margin: 4px 0 0;
}
.app-nav button {
width: 100%;
margin-top: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
@@ -181,6 +275,38 @@ nav button {
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 {
background: var(--color-hoch-bg);
color: var(--color-hoch);
@@ -266,6 +392,16 @@ nav button {
color: var(--color-mittel);
}
.status-niedrig {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-hoch {
background: var(--color-hoch-bg);
color: var(--color-hoch);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
@@ -275,7 +411,12 @@ nav button {
gap: 8px;
}
.beitraege-liste li a {
.beitraege-liste li a,
/* Einträge ohne umschließenden Link (Zeilen mit Badge/Aktions-Buttons
statt Navigation, z. B. Nutzer-/Abteilungsliste) bekommen dieselbe
Karte direkt auf dem <li> — :has() unterscheidet beide Fälle, damit
ein verlinkter Eintrag nicht doppelt (li UND a) gerahmt wird. */
.beitraege-liste > li:not(:has(> a)) {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -288,6 +429,15 @@ nav button {
text-decoration: none;
}
.beitraege-liste > li:not(:has(> a)) form {
margin: 0;
flex: 0 0 auto;
}
.beitraege-liste > li:not(:has(> a)) button {
margin-top: 0;
}
.beteiligte {
list-style: none;
margin: 0 0 16px;
@@ -405,6 +555,126 @@ button.entfernen {
margin: 4px 12px 4px 0;
}
/* ── Formular-Karten (Enterprise-Look, siehe design/enterprise.css
.ant-card/.ant-form-item) ─────────────────────────────────────────
Ersetzt "ein Formular = eine flache, ununterbrochene Spalte" durch
klar abgegrenzte Abschnitte mit Titel und ein Grid für kurze Felder
nebeneinander statt jedes Feld einzeln untereinander. */
.form-card {
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: 24px;
overflow: hidden;
}
.form-card form {
margin-bottom: 0;
}
.form-section {
border: none;
margin: 0;
padding: 20px;
min-width: 0;
}
.form-section + .form-section {
border-top: 1px solid var(--color-border);
}
.form-section > legend {
padding: 0 0 12px;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-heading);
width: 100%;
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0 16px;
}
@media (min-width: 640px) {
.form-grid {
grid-template-columns: 1fr 1fr;
}
.form-grid .form-full {
grid-column: 1 / -1;
}
}
/* Checkbox-/Radio-Gruppen als Chips statt einer langen
Untereinander-Liste — v. a. bei den zwölf Zweck-Kategorien war das
vorher eine kaum überblickbare Spalte. */
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.chip-group label {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 0;
padding: 7px 14px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text);
background: var(--color-bg);
cursor: pointer;
}
.chip-group input[type="checkbox"],
.chip-group input[type="radio"] {
width: auto;
min-height: 0;
margin: 0;
}
.chip-group label:has(input:checked) {
border-color: var(--branding-primary);
background: rgba(22, 119, 255, 0.08);
color: var(--branding-primary);
}
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
background: var(--color-bg);
}
.form-actions form,
.form-actions button {
margin: 0;
}
.form-gefahrenzone {
border-top: 1px solid var(--color-border);
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.form-gefahrenzone form {
margin: 0;
}
.fragebogen fieldset input[type="radio"] {
width: auto;
min-height: 0;
@@ -424,7 +694,51 @@ button.entfernen {
display: block;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
/* Tabellen (z. B. Register-Export-Ansicht) — bewusst horizontal
scrollbar in einem eigenen Wrapper statt schrumpfender Spalten: bei
neun Spalten wie im Register würde Schrumpfen nur unlesbaren Text
erzeugen, siehe .table-scroll in register_liste.html. */
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: var(--radius-md);
overflow: hidden;
font-size: 0.875rem;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
th {
background: var(--color-bg);
color: var(--color-heading);
font-weight: 600;
font-size: 0.8125rem;
}
tbody tr:hover {
background: var(--color-bg);
}
/* Ohne dieses Signal sieht eine breite, am Fensterrand abgeschnittene
Tabelle wie ein Rendering-Fehler aus statt wie "hier geht's mit
Scrollen weiter" — der Fade ist bewusst immer sichtbar (kein JS, das
den Scroll-Zustand prüfen könnte), auch am Ende der Tabelle. */
.table-scroll {
overflow-x: auto;
box-shadow: var(--shadow);
border-radius: var(--radius-md);
margin-bottom: 16px;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
}
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
.page {
@@ -435,3 +749,74 @@ button.entfernen {
font-size: 1.75rem;
}
}
/* Ab Desktop-Breite: dieselbe .topbar/.app-nav-Markup wird zu einer
permanent sichtbaren, dunklen Seitenleiste — kein separates
Desktop-Template, nur CSS. Der `details`/`summary`-Aufklapp-
Mechanismus wird hier überschrieben (Inhalt immer sichtbar,
summary versteckt), das Handy-Verhalten bleibt darunter unverändert. */
@media (min-width: 960px) {
.topbar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 8px;
padding: 20px 16px;
background: var(--sidebar-bg);
border-bottom: none;
border-right: 1px solid rgba(255, 255, 255, 0.08);
z-index: 10;
}
.brand {
color: #fff;
font-size: 1.125rem;
margin-bottom: 12px;
}
.nav-toggle-label {
display: none;
}
.app-nav {
display: flex;
position: static;
min-width: 0;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.app-nav a {
color: var(--sidebar-text);
}
.app-nav a:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
}
.app-nav button {
color: var(--sidebar-text);
border-color: rgba(255, 255, 255, 0.15);
}
.app-nav button:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
filter: none;
}
.page {
margin-left: var(--sidebar-width);
max-width: 1100px;
padding: 32px 40px;
width: 100%;
}
}

View File

@@ -15,7 +15,7 @@
<li>
{{.Name}}
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
<button type="submit">Löschen</button>
<button type="submit" class="entfernen">Löschen</button>
</form>
</li>
{{end}}

View File

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

View File

@@ -8,11 +8,25 @@
<h1>{{.Name}}</h1>
<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>
<ul class="beteiligte">
{{range .Users}}
<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>
{{end}}
</ul>

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

View File

@@ -19,6 +19,7 @@
{{end}}
</ul>
{{end}}
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
</div>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber/werkzeuge">&larr; Werkzeugkatalog</a></p>
<p><a href="{{.ActionBase}}">&larr; Werkzeugkatalog</a></p>
<h1>{{.Title}}</h1>
<p class="hinweis">
Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out,
@@ -12,54 +12,107 @@
Pflicht.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="{{if .ID}}/betreiber/werkzeuge/{{.ID}}{{else}}/betreiber/werkzeuge{{end}}">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
<label for="anbieter">Anbieter</label>
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
<fieldset>
<legend>Verarbeitungsort</legend>
{{$gewaehlt := .Verarbeitungsort}}
{{range .Verarbeitungsorte}}
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
{{end}}
<div class="form-card">
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
<fieldset class="form-section">
<legend>Grunddaten</legend>
<div class="form-grid">
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
</div>
<div>
<label for="anbieter">Anbieter</label>
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
</div>
<div class="form-full">
<label>Verarbeitungsort</label>
{{$gewaehlt := .Verarbeitungsort}}
<div class="chip-group">
{{range .Verarbeitungsorte}}
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
{{end}}
</div>
</div>
</div>
</fieldset>
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
<label for="avv_url">AVV-URL</label>
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
<fieldset class="form-section">
<legend>Rechtliches &amp; Datenschutz</legend>
<div class="form-grid">
<div class="form-full">
<label>Zusicherungen</label>
<div class="chip-group">
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
</div>
</div>
<div class="form-full">
<label for="avv_url">AVV-URL</label>
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
</div>
<div>
<label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label>
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
</div>
<div>
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
</div>
<div class="form-full">
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
</div>
</div>
</fieldset>
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
<fieldset class="form-section">
<legend>Einsatzbereich</legend>
<div class="form-grid">
<div class="form-full">
<label>Geeignete Zwecke</label>
<div class="chip-group">
{{range .ZweckeOptionen}}
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
{{end}}
</div>
</div>
<div class="form-full">
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
</div>
</div>
</fieldset>
<label for="aufbewahrung_tage">Aufbewahrung (Tage)</label>
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
<fieldset class="form-section">
<legend>Beleg</legend>
<div class="form-grid">
<div>
<label for="letzte_pruefung">Letzte Prüfung</label>
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
</div>
<div>
<label for="quelle">Quelle (Beleg)</label>
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
</div>
</div>
</fieldset>
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
<label for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
<label for="letzte_pruefung">Letzte Prüfung</label>
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
<label for="quelle">Quelle (Beleg)</label>
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
<button type="submit">Speichern</button>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
{{if .ID}}
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
<button type="submit">Werkzeug löschen</button>
</form>
<div class="form-gefahrenzone">
<span class="hinweis">Ein gelöschtes Werkzeug kann nicht wiederhergestellt werden.</span>
<form method="post" action="{{.ActionBase}}/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
<button type="submit" class="entfernen">Werkzeug löschen</button>
</form>
</div>
{{end}}
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -9,15 +9,32 @@
{{if not .Werkzeuge}}
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Werkzeuge}}
<li>
<a href="/betreiber/werkzeuge/{{.ID}}">
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
</a>
</li>
{{end}}
</ul>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
<th>Zuletzt geprüft</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.Zertifizierungen}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,19 @@
{{template "nav" .Nav}}
<div class="page">
<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">
<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>
{{end}}
</div>
</body>
</html>

View File

@@ -6,21 +6,45 @@
<script src="/static/htmx.min.js"></script>
{{end}}
{{/* Eine einzige Nav-Definition für alle Seiten (siehe navData in
middleware.go) — auf dem Handy ein aufklappbares Menü (Checkbox-
Hack, kein JavaScript: das <details>-Element scheitert daran, dass
aktuelle Chromium-Versionen dessen Auf-/Zuklapp-Zustand über eine
interne, nicht per CSS überschreibbare Animation steuern), ab
Desktop-Breite per CSS in eine permanent sichtbare Seitenleiste
verwandelt. Dieselbe Markup-Struktur bedient beide Layouts, siehe
app.css .app-nav. Die Checkbox ist bei jedem Seitenaufruf frisch
unchecked (serverseitig gerendert, kein Zustand über Navigation
hinweg nötig). */}}
{{define "nav"}}
<nav>
<a href="/">Start</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<header class="topbar">
<a class="brand" href="/">Deklarix</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen">Menü</label>
<nav class="app-nav">
<a href="/">Start</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout">
<button type="submit">Abmelden</button>
</form>
</nav>
</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">Abmelden</button>
<button type="submit">Sitzung beenden</button>
</form>
</nav>
</div>
{{end}}
{{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-

View File

@@ -5,33 +5,82 @@
{{template "nav" .Nav}}
<div class="page">
<h1>Werkzeugkatalog</h1>
<h2>Zentraler Katalog</h2>
<p class="hinweis">
Der zentrale Katalog wird von der Plattform gepflegt. Ein gesperrtes
Werkzeug bleibt im zentralen Katalog unverändert — es wird nur für
diesen Mandanten aus der Werkzeugsuche ausgeblendet.
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
der Werkzeugsuche ausgeblendet.
</p>
{{if not .Werkzeuge}}
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Werkzeuge}}
<li>
{{.Name}} · {{.Anbieter}}
{{if .Gesperrt}}
<span class="status">gesperrt</span>
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
<button type="submit">Entsperren</button>
</form>
{{else}}
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
<input type="text" name="grund" placeholder="Grund (optional)">
<button type="submit">Sperren</button>
</form>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td>{{.Name}}</td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
<td>
{{if .Gesperrt}}
<span class="status">gesperrt</span>
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
<button type="submit">Entsperren</button>
</form>
{{else}}
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
<input type="text" name="grund" placeholder="Grund (optional)">
<button type="submit">Sperren</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</li>
{{end}}
</ul>
</tbody>
</table>
</div>
{{end}}
<h2>Eigene Werkzeuge</h2>
<p class="hinweis">
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
hier, nicht im zentralen Katalog der Plattform.
</p>
{{if not .EigeneWerkzeuge}}
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
{{else}}
<div class="table-scroll">
<table>
<thead>
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>Zuletzt geprüft</th></tr>
</thead>
<tbody>
{{range .EigeneWerkzeuge}}
<tr>
<td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{.LetztePruefung}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
{{define "nutzer-import"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/verwaltung/nutzer">&larr; Nutzerverwaltung</a></p>
<h1>Mitarbeiter per CSV importieren</h1>
<p class="hinweis">
CSV mit Kopfzeile, Pflichtspalte "email", optionale Spalte "role"
(mitarbeiter/verantwortlicher/pruefer/admin — Standard: mitarbeiter).
Es gibt keine Einladungsmail: nach dem Import wird pro Zeile ein
Einmalpasswort angezeigt, das nur DIESES EINE MAL sichtbar ist — bitte
auf dieser Seite kopieren und sicher weitergeben, bevor sie
geschlossen wird.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
<button type="submit">Importieren</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,33 @@
{{define "nutzer-import-ergebnis"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Import-Ergebnis</h1>
<p class="fehler">
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
angezeigt — jetzt kopieren und sicher weitergeben.
</p>
<div class="table-scroll">
<table>
<thead>
<tr><th>Zeile</th><th>E-Mail</th><th>Rolle</th><th>Ergebnis</th></tr>
</thead>
<tbody>
{{range .Ergebnisse}}
<tr>
<td>{{.Zeile}}</td>
<td>{{.Email}}</td>
<td>{{if .Fehler}}—{{else}}{{.Role}}{{end}}</td>
<td>{{if .Fehler}}<span class="fehler">{{.Fehler}}</span>{{else}}<code>{{.Passwort}}</code>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p><a href="/verwaltung/nutzer">Zur Nutzerverwaltung</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -13,7 +13,7 @@
<span class="status status-published">aktiv</span>
{{if not .IstSelbst}}
<form method="post" action="/verwaltung/nutzer/{{.ID}}/deaktivieren" style="display:inline">
<button type="submit">Deaktivieren</button>
<button type="submit" class="entfernen">Deaktivieren</button>
</form>
{{end}}
{{else}}
@@ -25,7 +25,7 @@
</li>
{{end}}
</ul>
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p>
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a> · <a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
</div>
</body>
</html>

View File

@@ -13,6 +13,7 @@
{{if not .Eintraege}}
<p class="hinweis">Noch keine Genehmigungen.</p>
{{else}}
<div class="table-scroll">
<table>
<thead>
<tr>
@@ -31,6 +32,7 @@
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
</body>

View File

@@ -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
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
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
diese Reihenfolge an (niedrigster zu höchstem Rang):
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt diese
Reihenfolge an (niedrigster zu höchstem Rang) — **vom Produktverantwortlichen
bestätigt, keine Änderung nötig:**
1. `oeffentlich`
2. `intern`
@@ -18,22 +19,22 @@ diese Reihenfolge an (niedrigster zu höchstem Rang):
5. `berufsgeheimnis`
6. `besondere_kategorie`
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
bestätigt.**
Begründung: DSGVO Art. 9 (besondere Kategorien) gilt allgemein als
striktester Datenschutz-Tatbestand, § 203 StGB (Berufsgeheimnis) hat
eigene strafrechtliche Relevanz — beide vor "normalen" personenbezogenen
Daten eingeordnet. `auftragsdaten` unter `personenbezogen` einsortiert,
weil vertragliche Geheimhaltung in der Regel schwächer sanktioniert ist
als DSGVO-Bußgelder.
## 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" —
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
`rules/anforderungen.yaml` schließt `auftragsdaten` ein (Kundendaten
sollten aus denselben Gründen wie Geschäftsgeheimnisse nicht zum
Training verwendet werden) — **vom Produktverantwortlichen bestätigt,
keine Änderung nötig.**
## 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`,
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
final, nicht mehr Platzhalter. Ob diese drei Varianten fachlich
vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
**bestätigt (keine Ergänzung nötig).**
## 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,
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
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
bestätigt**, nur eine sichere Standardannahme.
Zweifel mehr Schutzanforderungen, nicht weniger. **Vom
Produktverantwortlichen bestätigt, keine Änderung nötig.**
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?