Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc99858c94 | ||
|
|
3aecbb446c | ||
|
|
08c7721165 | ||
|
|
59e1bcc8f7 | ||
|
|
2e459f1dd1 | ||
|
|
d77b19f61f | ||
|
|
dbeaa45644 | ||
|
|
4358dbaa0d | ||
|
|
7397f70068 | ||
|
|
2736e2c0db | ||
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 |
301
CLAUDE.md
301
CLAUDE.md
@@ -39,8 +39,8 @@ Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
|
|||||||
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
||||||
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
||||||
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
||||||
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
|
Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
|
||||||
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
|
erkennen, siehe `gueltigeZwecke` weiter unten), nie für die Einstufung
|
||||||
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -115,11 +115,46 @@ reiner Anzeige-Screen reicht nicht):
|
|||||||
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
||||||
erreichbar (404).
|
erreichbar (404).
|
||||||
|
|
||||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
|
**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
|
||||||
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
|
`GET /betreiber/accounts/{id}` kann sich der Betreiber über
|
||||||
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
|
`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
|
||||||
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
|
anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
|
||||||
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
|
`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
|
||||||
|
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).
|
wurde beim Produktwechsel sogar entfernt).
|
||||||
|
|
||||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||||
@@ -216,11 +251,138 @@ Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
|
|||||||
zentralen Katalog zu verändern.
|
zentralen Katalog zu verändern.
|
||||||
|
|
||||||
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
|
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
|
||||||
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
|
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsländer, Training-Opt-
|
||||||
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
||||||
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
|
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
|
||||||
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
|
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
|
||||||
nicht in der UI umgesetzt, siehe Offene Punkte).
|
`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, 150–180 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, Verarbeitungsländer, DPF, 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.
|
||||||
|
|
||||||
|
**Filter & Paginierung** (seit 2026-08-31, `pagination.go` +
|
||||||
|
`werkzeugFilter` in `betreiber_werkzeug_handlers.go`): beide
|
||||||
|
Katalog-Tabellen haben eine Filterleiste (Freitext über Name/Anbieter/
|
||||||
|
Verarbeitungsländer, Dropdown über die zwölf Zweck-Kategorien) und
|
||||||
|
serverseitige Paginierung (20 Einträge/Seite, `page`-Query-Parameter).
|
||||||
|
Alles über ein einziges GET-Formular, ohne JavaScript — Prev/Next-
|
||||||
|
Buttons setzen `page` per `<button type="submit" name="page"
|
||||||
|
value="...">`, damit q/zweck beim Blättern erhalten bleiben. Auf der
|
||||||
|
Mandanten-Seite (die pro Zeile zusätzlich Sperren/Entsperren-Formulare
|
||||||
|
hat) sind Filter und Paginierungs-Buttons bewusst in getrennte
|
||||||
|
`<form>`-Elemente aufgeteilt statt in eines um die ganze Tabelle —
|
||||||
|
verschachtelte `<form>`-Elemente sind ungültiges HTML, die
|
||||||
|
Paginierungs-Buttons tragen `q`/`zweck` deshalb als Hidden-Felder
|
||||||
|
nach. `pagination.go` ist bewusst generisch (Go-Generics,
|
||||||
|
`paginate[T any]`) für Wiederverwendung in weiteren Listen (Register,
|
||||||
|
Fälle, ...) — bisher nur auf den Werkzeugkatalog angewendet, das war
|
||||||
|
die von den beiden am dringendsten wachsende Tabelle.
|
||||||
|
|
||||||
|
Der zentrale Katalog ist aktuell mit 20 recherchierten Einträgen
|
||||||
|
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
|
||||||
|
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
|
||||||
|
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
|
||||||
|
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
|
||||||
|
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
|
||||||
|
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
|
||||||
|
mindestens einem Eintrag ab. **Claude Code ist bewusst ein eigener
|
||||||
|
Eintrag neben Claude for Work**, kein Duplikat: es läuft lokal auf
|
||||||
|
Entwicklerrechnern mit direktem Zugriff auf Dateien/Codebasen und
|
||||||
|
speichert Sitzungsprotokolle lokal im Klartext (bis zu 30 Tage,
|
||||||
|
konfigurierbar) — ein zusätzliches lokales Aufbewahrungsrisiko, das bei
|
||||||
|
einer reinen Web-Chat-Anwendung wie Claude for Work nicht existiert,
|
||||||
|
obwohl beide über dieselben Commercial Terms/DPA laufen. Jede
|
||||||
|
Zusicherung ist mit einer echten,
|
||||||
|
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
|
||||||
|
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
|
||||||
|
hergibt: Synthesia und Zapier haben `training_standard`/
|
||||||
|
`training_opt_out` bewusst NICHT gesetzt, weil keine belegte Aussage
|
||||||
|
zum Trainingsausschluss gefunden wurde (nicht mit "trainiert
|
||||||
|
nachweislich" verwechseln — es ist ein "wissen wir nicht, deshalb
|
||||||
|
keine Haken"). Mehrere Einträge sind bewusst mit kritischen
|
||||||
|
Einschränkungen versehen statt schöngefärbt, das ist die vorgesehene
|
||||||
|
Funktion des Katalogs: Adobe Firefly (Trainingsausschluss ist nicht
|
||||||
|
Standard), Otter.ai (Training auf de-identifizierten Aufnahmen,
|
||||||
|
laufende Rechtsstreitigkeiten), **Midjourney (kein AVV, keine
|
||||||
|
EU-Datenresidenz, unbegrenzte Speicherung inkl. Training — laut
|
||||||
|
CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
|
||||||
|
geeignet, absichtlich als Negativbeispiel im Katalog, nicht
|
||||||
|
weggelassen)**.
|
||||||
|
|
||||||
|
**Schema-Lücke behoben (2026-08-31, Migration 0016):** der grobe
|
||||||
|
`verarbeitungsort`-Eimer (`EU`/`USA`/`gemischt`/`on-prem`) wurde durch
|
||||||
|
`verarbeitungslaender TEXT[]` ersetzt — echte Länder statt Buckets.
|
||||||
|
Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
|
||||||
|
ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
|
||||||
|
Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
|
||||||
|
bietet dafür eine echte Weltländerliste (`weltLaenderliste` in
|
||||||
|
`laenderliste.go`, `<select multiple>`, Mehrfachauswahl per Strg/Cmd-
|
||||||
|
Klick — kein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
|
||||||
|
(kein einzelnes Land benannt)" für Anbieter, die nur eine Region statt
|
||||||
|
eines Staates zusichern, z. B. Microsofts EU Data Boundary; "On-Premise
|
||||||
|
(selbst gehostet)"). Der harte Filter `eu_verarbeitung`
|
||||||
|
(`internal/rules/evaluate.go`, `alleLaenderInEUEWR`) verlangt jetzt,
|
||||||
|
dass ALLE genannten Länder EU/EWR-Mitgliedstaaten sind — eine leere
|
||||||
|
Liste gilt als nicht erfüllt, nicht als Freifahrtschein. Zusätzlich neues
|
||||||
|
Feld `dpf_zertifiziert` (Checkbox "EU-US Data Privacy Framework
|
||||||
|
zertifiziert"): DPF ist eine schmalere, gerichtlich schon zweimal
|
||||||
|
gekippte Rechtsgrundlage (Safe Harbor, Privacy Shield) für USA-Transfers,
|
||||||
|
bisher nur als Fließtext in "Einschränkungen" erwähnt, jetzt strukturiert
|
||||||
|
und damit künftig filterbar. Alle 19 Katalogeinträge wurden mit den
|
||||||
|
tatsächlichen, bereits recherchierten Ländern nachgepflegt (z. B.
|
||||||
|
Mistral → Frankreich, DeepL → Deutschland, Synthesia → Irland, Claude
|
||||||
|
for Work → USA) statt der pauschalen alten Buckets.
|
||||||
|
|
||||||
|
Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen
|
||||||
|
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
|
||||||
|
Wertebereich, nicht an einer fachlichen Entscheidung.
|
||||||
|
|
||||||
|
**Offen:** `dpf_zertifiziert` ist bei allen 19 Einträgen noch `false` —
|
||||||
|
für keinen wurde bisher gezielt recherchiert, ob der jeweilige
|
||||||
|
US-Anbieter tatsächlich DPF-zertifiziert ist (das Feld wurde erst mit
|
||||||
|
dieser Migration eingeführt, `false` heißt hier "nicht recherchiert",
|
||||||
|
nicht "nachweislich nicht zertifiziert"). Nachrecherche pro Anbieter
|
||||||
|
noch zu tun.
|
||||||
|
|
||||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||||
@@ -237,8 +399,8 @@ bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
|
|||||||
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
||||||
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
||||||
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
||||||
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
|
(die zwölf Kategorien aus `gueltigeZwecke`, siehe Werkzeugkatalog
|
||||||
und gegen `geeignete_zwecke` filtern.
|
weiter unten) und gegen `geeignete_zwecke` filtern.
|
||||||
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
||||||
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
||||||
Prüfung.
|
Prüfung.
|
||||||
@@ -296,7 +458,7 @@ wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
|
|||||||
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
||||||
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
||||||
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
|
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
|
||||||
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine
|
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsländern. Keine
|
||||||
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
|
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
|
||||||
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
|
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
|
||||||
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
|
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
|
||||||
@@ -385,7 +547,90 @@ kein separates Desktop-Template.
|
|||||||
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
||||||
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
||||||
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
||||||
Scrollen auf dem Handy statt gequetschter Spalten).
|
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-Gruppen mit überschaubar vielen Optionen (Zusicherungen, 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.
|
||||||
|
|
||||||
|
**Typografie-Nachbesserung (2026-08-31):** Nutzer-Feedback nach dem
|
||||||
|
ersten Formular-Redesign: Überschriften wirkten zu klein und zu dicht
|
||||||
|
am Rand. Ursache für Letzteres: `<legend>` in `<fieldset
|
||||||
|
class="form-section">` positioniert sich per Spec an der Border-Box
|
||||||
|
des Fieldsets, nicht an dessen Padding-Box — ohne eigenes Padding sitzt
|
||||||
|
die Abschnittsüberschrift optisch enger am Kartenrand als der übrige,
|
||||||
|
per `.form-section { padding: 20px }` eingerückte Inhalt. `.form-section
|
||||||
|
> legend` hat jetzt selbst `padding: 0 0 16px 0` und eine größere
|
||||||
|
Schrift (1.0625rem/700 statt 0.9375rem/600). Gleichzeitig `h2`
|
||||||
|
(1.25rem) und `h3` (1.0625rem) erstmals explizit gesetzt (vorher reiner
|
||||||
|
Browser-Default) und `.fragebogen legend` auf dieselbe Gewichtsstufe
|
||||||
|
wie die Formular-Abschnitte angehoben (600/1rem statt 500/0.9375rem) —
|
||||||
|
für eine einheitliche Überschriften-Hierarchie statt Zufallsgrößen je
|
||||||
|
nach UA-Stylesheet.
|
||||||
|
|
||||||
|
**enconf-Patterns übertragen (2026-08-31):** enconf (Schwesterprojekt)
|
||||||
|
ist React + Ant Design (`ProTable`/`StatCard`/`PageHeader`-Komponenten)
|
||||||
|
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die
|
||||||
|
*Muster* wurden als reines CSS/HTML nachgebaut (recherchiert über die
|
||||||
|
Architect-RAG-Suche, `ac_search_code_global`, da das enconf-Repo selbst
|
||||||
|
außerhalb des Arbeitsverzeichnisses liegt):
|
||||||
|
|
||||||
|
- **`.table-responsive`** (ersetzt `.table-scroll` schrittweise) —
|
||||||
|
enconfs `ProTable.tsx` schaltet auf schmalen Screens von Tabelle auf
|
||||||
|
eine Card-Liste um (`renderMobileCard` via `Grid.useBreakpoint()`),
|
||||||
|
statt horizontal zu scrollen. Nachgebaut rein in CSS: unter 640px
|
||||||
|
werden `<tr>` zu Karten, `<td>` zu Label/Wert-Zeilen (`content:
|
||||||
|
attr(data-label)` im `::before`) — jedes `<td>` braucht dafür ein
|
||||||
|
`data-label="Spaltenname"`-Attribut. Ab 640px normale Tabelle mit
|
||||||
|
Scroll-Fade als Sicherheitsnetz. Bisher angewendet auf beide
|
||||||
|
Werkzeugkatalog-Tabellen; `.table-scroll` (nur Scroll-Fade, keine
|
||||||
|
Card-Umschaltung) bleibt für noch nicht migrierte Tabellen (Register,
|
||||||
|
Nutzer-Import-Ergebnis) bestehen.
|
||||||
|
- **`.page-header`** (Titel+Unterzeile links, primäre Aktion rechts,
|
||||||
|
stapelt auf Mobile) — ersetzt einen nackten `<h1>` mit einem Link
|
||||||
|
irgendwo unten auf der Seite. Bisher auf den Werkzeugkatalog
|
||||||
|
angewendet (Betreiber + Mandant, inkl. "Eigene Werkzeuge"-Abschnitt).
|
||||||
|
- **`.stat-cards`/`.stat-card`** — Kennzahl-Kacheln mit farbigem oberem
|
||||||
|
Akzentrand (blau normal, gelb/rot nur wenn der Wert > 0 ist — keine
|
||||||
|
Farbe ohne Grund) statt einfacher Text-Links; `.stat-card-action` für
|
||||||
|
die primäre Handlungs-Kachel (z. B. "Antrag stellen"). Kein
|
||||||
|
Icon-Set/keine Icon-Bibliothek eingeführt (enconf nutzt
|
||||||
|
`@ant-design/icons`, das ist React-spezifisch) — bewusste
|
||||||
|
Vereinfachung. Angewendet auf die Startseite (`index.html`).
|
||||||
|
|
||||||
|
Noch nicht auf weitere Tabellen/Seiten übertragen (Register,
|
||||||
|
Nutzerliste, Abteilungen, Fälle-Posteingang, Betreiber-Dashboard) —
|
||||||
|
nächste Kandidaten, falls das Muster fortgesetzt werden soll.
|
||||||
|
|
||||||
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||||
@@ -734,16 +979,24 @@ journalctl -u deklarix -f
|
|||||||
|
|
||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
- ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
|
||||||
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
|
2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
|
||||||
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
|
nötig.
|
||||||
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
|
- ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
|
||||||
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
|
(`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
|
||||||
bleiben, aber die Ableitung kann sich noch ändern.
|
eingeschlossen.
|
||||||
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
||||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
|
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
|
||||||
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
|
die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
|
||||||
(siehe `rules/OPEN.md`, Punkt 3).
|
ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
|
||||||
|
- ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
|
||||||
|
— **bestätigt**, `intern` bleibt die konservative Standardannahme.
|
||||||
|
- **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
|
||||||
|
(`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
|
||||||
|
`kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
|
||||||
|
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
||||||
|
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
||||||
|
Rückmeldung dazu bisher eingeholt.
|
||||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||||
|
|||||||
@@ -187,12 +187,48 @@ func containsString(list []string, v string) bool {
|
|||||||
// unabhängig von internal/store und für sich allein testbar.
|
// unabhängig von internal/store und für sich allein testbar.
|
||||||
type WerkzeugEigenschaften struct {
|
type WerkzeugEigenschaften struct {
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
Verarbeitungsort string
|
// Verarbeitungslaender nennt die tatsächlichen Länder statt eines
|
||||||
|
// groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
|
||||||
|
// eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
|
||||||
|
// genannten Länder EU/EWR-Mitgliedstaaten sind.
|
||||||
|
Verarbeitungslaender []string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
AufbewahrungTage int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
|
||||||
|
// Drittlandtransfer gilt (Art. 44 ff. DSGVO) — die 27 EU-Mitgliedstaaten
|
||||||
|
// plus die drei über den EWR-Vertrag gleichgestellten Staaten. Die USA
|
||||||
|
// gehören bewusst NICHT dazu — sie sind ein Drittland wie jedes andere,
|
||||||
|
// unabhängig vom EU-US Data Privacy Framework (das mildert die
|
||||||
|
// Transfer-Grundlage, macht die USA aber nicht zum EU/EWR-Gebiet).
|
||||||
|
var euEwrLaender = map[string]bool{
|
||||||
|
"Belgien": true, "Bulgarien": true, "Dänemark": true, "Deutschland": true,
|
||||||
|
"Estland": true, "Finnland": true, "Frankreich": true, "Griechenland": true,
|
||||||
|
"Irland": true, "Italien": true, "Kroatien": true, "Lettland": true,
|
||||||
|
"Litauen": true, "Luxemburg": true, "Malta": true, "Niederlande": true,
|
||||||
|
"Österreich": true, "Polen": true, "Portugal": true, "Rumänien": true,
|
||||||
|
"Schweden": true, "Slowakei": true, "Slowenien": true, "Spanien": true,
|
||||||
|
"Tschechien": true, "Ungarn": true, "Zypern": true,
|
||||||
|
"Island": true, "Liechtenstein": true, "Norwegen": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// alleLaenderInEUEWR prüft, ob jedes genannte Land EU/EWR ist. Eine
|
||||||
|
// leere Liste (kein Land benannt) gilt als NICHT erfüllt — "wissen wir
|
||||||
|
// nicht" darf nie stillschweigend als "ist okay" durchgehen.
|
||||||
|
func alleLaenderInEUEWR(laender []string) bool {
|
||||||
|
if len(laender) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range laender {
|
||||||
|
if !euEwrLaender[l] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||||
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
||||||
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||||
@@ -206,7 +242,7 @@ func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
|||||||
case "avv_erforderlich":
|
case "avv_erforderlich":
|
||||||
return w.AVVVerfuegbar
|
return w.AVVVerfuegbar
|
||||||
case "eu_verarbeitung":
|
case "eu_verarbeitung":
|
||||||
return w.Verarbeitungsort == "EU"
|
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||||
case "kein_training_auf_eingabe":
|
case "kein_training_auf_eingabe":
|
||||||
return w.TrainingStandard
|
return w.TrainingStandard
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -158,6 +158,21 @@ func TestErfuelltAnforderungAVV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}) {
|
||||||
|
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||||
@@ -166,8 +181,8 @@ func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
|||||||
|
|
||||||
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||||
kandidaten := []rules.WerkzeugKandidat{
|
kandidaten := []rules.WerkzeugKandidat{
|
||||||
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}},
|
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}},
|
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
}
|
}
|
||||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
||||||
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||||
|
// Betreiber-gestützten Anlage korrigieren).
|
||||||
|
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: update account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||||
|
|||||||
@@ -30,6 +30,39 @@ func TestAccountCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetAccountByEinladungToken(t *testing.T) {
|
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -191,6 +224,9 @@ func TestSessionCRUD(t *testing.T) {
|
|||||||
if got.UserID != user.ID {
|
if got.UserID != user.ID {
|
||||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||||
}
|
}
|
||||||
|
if got.ImpersonatedByUserID != nil {
|
||||||
|
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||||
t.Fatalf("DeleteSession: %v", err)
|
t.Fatalf("DeleteSession: %v", err)
|
||||||
@@ -207,3 +243,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateImpersonatedSession(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
kundenAcc := testAccountID(t, s)
|
||||||
|
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||||
|
}
|
||||||
|
betreiberAcc := testAccountID(t, s)
|
||||||
|
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||||
|
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||||
|
}
|
||||||
|
if sess.UserID != kundenNutzer.ID {
|
||||||
|
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||||
|
}
|
||||||
|
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSession(ctx, sess.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||||
|
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||||
|
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||||
|
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||||
|
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||||
|
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||||
|
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||||
|
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||||
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal 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;
|
||||||
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal 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 '{}';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
|
||||||
|
|
||||||
|
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
|
||||||
|
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
|
||||||
|
-- bestmögliche Näherung statt Datenverlust durch NULL.
|
||||||
|
UPDATE werkzeug SET verarbeitungsort =
|
||||||
|
CASE
|
||||||
|
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
|
||||||
|
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
|
||||||
|
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
|
||||||
|
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
|
||||||
|
ELSE 'EU'
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
|
||||||
|
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
|
||||||
|
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
|
||||||
|
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
|
||||||
|
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
|
||||||
|
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
|
||||||
|
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
|
||||||
|
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
|
||||||
|
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
|
||||||
|
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
|
||||||
|
-- OpenAI/Microsoft).
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
|
||||||
|
|
||||||
|
UPDATE werkzeug SET verarbeitungslaender =
|
||||||
|
CASE verarbeitungsort
|
||||||
|
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
|
||||||
|
WHEN 'USA' THEN ARRAY['USA']
|
||||||
|
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
|
||||||
|
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
|
||||||
|
|
||||||
|
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
|
||||||
|
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
|
||||||
|
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
|
||||||
|
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
|
||||||
|
-- filterbar.
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -12,38 +12,70 @@ import (
|
|||||||
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||||
|
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||||
|
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||||
|
// statt durch den regulären Login des Nutzers selbst.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string
|
Token string
|
||||||
UserID string
|
UserID string
|
||||||
|
ImpersonatedByUserID *string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||||
|
|
||||||
|
func scanSession(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Session, error) {
|
||||||
|
var sess Session
|
||||||
|
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||||
|
return sess, err
|
||||||
|
}
|
||||||
|
|
||||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
var sess Session
|
row := s.Pool.QueryRow(ctx, `
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO session (token, user_id, expires_at)
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING token, user_id, expires_at, created_at
|
RETURNING `+sessionColumns,
|
||||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
token, userID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||||
}
|
}
|
||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||||
|
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||||
|
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||||
|
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||||
|
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||||
|
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+sessionColumns,
|
||||||
|
token, userID, impersonatedByUserID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
var sess Session
|
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
sess, err := scanSession(row)
|
||||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
|
||||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,26 @@ type Werkzeug struct {
|
|||||||
AccountID *string
|
AccountID *string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
|
||||||
|
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
|
||||||
|
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
|
||||||
|
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
|
||||||
|
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
|
||||||
|
Verarbeitungslaender []string
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
AVVURL string
|
AVVURL string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
|
||||||
|
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
|
||||||
|
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
|
||||||
|
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
|
||||||
|
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
|
||||||
|
// übrigen Booleans hier.
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
|
||||||
Zertifizierungen []string
|
Zertifizierungen []string
|
||||||
|
Subprozessoren []string
|
||||||
GeeigneteZwecke []string
|
GeeigneteZwecke []string
|
||||||
Einschraenkungen []string
|
Einschraenkungen []string
|
||||||
LetztePruefung time.Time
|
LetztePruefung time.Time
|
||||||
@@ -36,37 +49,39 @@ type Werkzeug struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
// 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.
|
// Parameterliste.
|
||||||
type WerkzeugInput struct {
|
type WerkzeugInput struct {
|
||||||
AccountID *string
|
AccountID *string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
Verarbeitungslaender []string
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
AVVURL string
|
AVVURL string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
DPFZertifiziert bool
|
||||||
|
AufbewahrungTage *int
|
||||||
Zertifizierungen []string
|
Zertifizierungen []string
|
||||||
|
Subprozessoren []string
|
||||||
GeeigneteZwecke []string
|
GeeigneteZwecke []string
|
||||||
Einschraenkungen []string
|
Einschraenkungen []string
|
||||||
LetztePruefung time.Time
|
LetztePruefung time.Time
|
||||||
Quelle string
|
Quelle string
|
||||||
}
|
}
|
||||||
|
|
||||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||||
|
|
||||||
func scanWerkzeug(row interface {
|
func scanWerkzeug(row interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}) (Werkzeug, error) {
|
}) (Werkzeug, error) {
|
||||||
var w Werkzeug
|
var w Werkzeug
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
|
||||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
|
||||||
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||||
)
|
)
|
||||||
return w, err
|
return w, err
|
||||||
}
|
}
|
||||||
@@ -77,6 +92,9 @@ func scanWerkzeug(row interface {
|
|||||||
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||||
// Fall wie früher bei finding.sources).
|
// Fall wie früher bei finding.sources).
|
||||||
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||||
|
if in.Verarbeitungslaender == nil {
|
||||||
|
in.Verarbeitungslaender = []string{}
|
||||||
|
}
|
||||||
if in.Zertifizierungen == nil {
|
if in.Zertifizierungen == nil {
|
||||||
in.Zertifizierungen = []string{}
|
in.Zertifizierungen = []string{}
|
||||||
}
|
}
|
||||||
@@ -86,6 +104,9 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
|||||||
if in.Einschraenkungen == nil {
|
if in.Einschraenkungen == nil {
|
||||||
in.Einschraenkungen = []string{}
|
in.Einschraenkungen = []string{}
|
||||||
}
|
}
|
||||||
|
if in.Subprozessoren == nil {
|
||||||
|
in.Subprozessoren = []string{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||||
@@ -93,13 +114,13 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
|
|||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO werkzeug (
|
INSERT INTO werkzeug (
|
||||||
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
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, $16)
|
||||||
RETURNING `+werkzeugColumns,
|
RETURNING `+werkzeugColumns,
|
||||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
)
|
)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
@@ -118,14 +139,14 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
|||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.Pool.QueryRow(ctx, `
|
||||||
UPDATE werkzeug SET
|
UPDATE werkzeug SET
|
||||||
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||||
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||||
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
|
||||||
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
letzte_pruefung = $15, quelle = $16, updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING `+werkzeugColumns,
|
RETURNING `+werkzeugColumns,
|
||||||
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
)
|
)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ import (
|
|||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func intPtr(v int) *int { return &v }
|
||||||
|
|
||||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||||
return store.WerkzeugInput{
|
return store.WerkzeugInput{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Anbieter: "Beispiel-Anbieter GmbH",
|
Anbieter: "Beispiel-Anbieter GmbH",
|
||||||
Verarbeitungsort: "EU",
|
Verarbeitungslaender: []string{"Deutschland"},
|
||||||
AVVVerfuegbar: true,
|
AVVVerfuegbar: true,
|
||||||
AVVURL: "https://beispiel.example/avv",
|
AVVURL: "https://beispiel.example/avv",
|
||||||
TrainingOptOut: true,
|
TrainingOptOut: true,
|
||||||
TrainingStandard: true,
|
TrainingStandard: true,
|
||||||
AufbewahrungTage: 30,
|
AufbewahrungTage: intPtr(30),
|
||||||
Zertifizierungen: []string{"ISO 27001"},
|
Zertifizierungen: []string{"ISO 27001"},
|
||||||
|
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
|
||||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||||
Einschraenkungen: nil,
|
Einschraenkungen: nil,
|
||||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
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" {
|
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||||
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
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)
|
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||||
updateInput.AufbewahrungTage = 14
|
updateInput.AufbewahrungTage = intPtr(14)
|
||||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateWerkzeug: %v", err)
|
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)
|
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,115 @@ var datenfragen = []struct {
|
|||||||
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var c2FolgeLabels = map[string]string{
|
||||||
|
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
|
||||||
|
"kreditwuerdigkeit": "Kreditwürdigkeit",
|
||||||
|
"leistungsbewilligung": "Leistungsbewilligung",
|
||||||
|
"bildung": "Zugang zu Bildung",
|
||||||
|
"gesundheit": "medizinische Beurteilung",
|
||||||
|
}
|
||||||
|
|
||||||
|
var c3ArtLabels = map[string]string{
|
||||||
|
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
|
||||||
|
"social_scoring": "Social Scoring",
|
||||||
|
"biometrische_kategorisierung": "Biometrische Kategorisierung",
|
||||||
|
}
|
||||||
|
|
||||||
|
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
|
||||||
|
// siehe antwortenAnzeige.
|
||||||
|
type antwortZeile struct {
|
||||||
|
Frage string
|
||||||
|
Antwort string
|
||||||
|
}
|
||||||
|
|
||||||
|
func antwortLabel(v string) string {
|
||||||
|
switch v {
|
||||||
|
case "ja":
|
||||||
|
return "Ja"
|
||||||
|
case "nein":
|
||||||
|
return "Nein"
|
||||||
|
case "unsicher":
|
||||||
|
return "Unsicher"
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jaNein(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "Ja"
|
||||||
|
}
|
||||||
|
return "Nein"
|
||||||
|
}
|
||||||
|
|
||||||
|
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
|
||||||
|
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
|
||||||
|
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
|
||||||
|
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
|
||||||
|
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
|
||||||
|
// Antworten, auf denen sie beruht — im Audit ist das nicht
|
||||||
|
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
|
||||||
|
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
|
||||||
|
var out []antwortZeile
|
||||||
|
for _, f := range datenfragen {
|
||||||
|
v, _ := antworten[f.Key].(string)
|
||||||
|
if v == "" {
|
||||||
|
v = "nein"
|
||||||
|
}
|
||||||
|
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
|
||||||
|
}
|
||||||
|
|
||||||
|
c1, _ := antworten["c1"].(bool)
|
||||||
|
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
|
||||||
|
|
||||||
|
c2, _ := antworten["c2"].(bool)
|
||||||
|
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
|
||||||
|
if c2 {
|
||||||
|
if folge, _ := antworten["c2_folge"].(string); folge != "" {
|
||||||
|
if label, ok := c2FolgeLabels[folge]; ok {
|
||||||
|
c2Zeile.Antwort += " — " + label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, c2Zeile)
|
||||||
|
|
||||||
|
c3, _ := antworten["c3"].(bool)
|
||||||
|
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
|
||||||
|
if c3 {
|
||||||
|
if art, _ := antworten["c3_art"].(string); art != "" {
|
||||||
|
if label, ok := c3ArtLabels[art]; ok {
|
||||||
|
c3Zeile.Antwort += " — " + label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, c3Zeile)
|
||||||
|
|
||||||
|
c4, _ := antworten["c4"].(bool)
|
||||||
|
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
|
||||||
|
|
||||||
|
c5, _ := antworten["c5"].(bool)
|
||||||
|
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
|
||||||
|
|
||||||
|
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
|
||||||
|
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
|
||||||
|
}
|
||||||
|
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
|
||||||
|
label := "Privater Zugang"
|
||||||
|
if zugang == "firma" {
|
||||||
|
label = "Firmenkonto"
|
||||||
|
}
|
||||||
|
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
|
||||||
|
}
|
||||||
|
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
|
||||||
|
label := "Privatgerät"
|
||||||
|
if geraet == "firma" {
|
||||||
|
label = "Firmengerät"
|
||||||
|
}
|
||||||
|
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
type antragFormData struct {
|
type antragFormData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
@@ -180,9 +289,13 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
|||||||
}
|
}
|
||||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||||
for i, w := range 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{
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||||
@@ -251,7 +364,6 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type anforderungView struct {
|
type anforderungView struct {
|
||||||
ID string
|
|
||||||
Beschreibung string
|
Beschreibung string
|
||||||
Herleitung string
|
Herleitung string
|
||||||
}
|
}
|
||||||
@@ -278,14 +390,13 @@ type bewertungView struct {
|
|||||||
type antragDetailData struct {
|
type antragDetailData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
ID string
|
|
||||||
Titel string
|
Titel string
|
||||||
Beschreibung string
|
Beschreibung string
|
||||||
Ergebnis string
|
Ergebnis string
|
||||||
Haeufigkeit string
|
Haeufigkeit string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
Antworten map[string]any
|
Antworten []antwortZeile
|
||||||
Bewertung *bewertungView
|
Bewertung *bewertungView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,9 +417,9 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := antragDetailData{
|
data := antragDetailData{
|
||||||
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
|
||||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
||||||
}
|
}
|
||||||
|
|
||||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
@@ -333,7 +444,7 @@ func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewert
|
|||||||
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
}
|
}
|
||||||
for _, a := range b.Anforderungen {
|
for _, a := range b.Anforderungen {
|
||||||
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
}
|
}
|
||||||
for _, id := range b.ZulaessigeWerkzeuge {
|
for _, id := range b.ZulaessigeWerkzeuge {
|
||||||
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||||
|
|||||||
@@ -75,8 +75,15 @@ func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
|||||||
if detailResp.Code != http.StatusOK {
|
if detailResp.Code != http.StatusOK {
|
||||||
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") {
|
body := detailResp.Body.String()
|
||||||
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String())
|
if !strings.Contains(body, "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the antrag title on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||||
|
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||||
|
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||||
|
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||||
|
// (Support-Login).
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
@@ -149,5 +157,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
98
internal/web/betreiber_account_crud_test.go
Normal file
98
internal/web/betreiber_account_crud_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
|
||||||
|
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if user.Role != "admin" {
|
||||||
|
t.Errorf("Role = %q, want admin", user.Role)
|
||||||
|
}
|
||||||
|
acc, err := fs.GetAccount(context.Background(), user.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if acc.Name != "Neue Firma GmbH" {
|
||||||
|
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der neue Login funktioniert tatsächlich.
|
||||||
|
loginResp := postForm(t, s, nil, "/login", url.Values{
|
||||||
|
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if loginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Pflicht") {
|
||||||
|
t.Errorf("expected a validation error, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Alter Name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := fs.GetAccount(context.Background(), acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name" {
|
||||||
|
t.Errorf("Name = %q, want Neuer Name", got.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "Neuer Name") {
|
||||||
|
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type betreiberDashboardData struct {
|
type betreiberDashboardData struct {
|
||||||
@@ -72,9 +75,103 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type betreiberAccountNeuData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
|
||||||
|
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
|
||||||
|
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
|
||||||
|
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
|
||||||
|
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
|
||||||
|
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
|
||||||
|
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountName := r.FormValue("account_name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if accountName == "" || email == "" || password == "" {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
|
||||||
|
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
|
||||||
|
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
|
||||||
|
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
|
||||||
|
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
|
||||||
|
// audit_log laufen).
|
||||||
|
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
|
||||||
|
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
type betreiberUserView struct {
|
type betreiberUserView struct {
|
||||||
|
ID string
|
||||||
Email string
|
Email string
|
||||||
Role string
|
Role string
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type betreiberAccountDetailData struct {
|
type betreiberAccountDetailData struct {
|
||||||
@@ -105,7 +202,7 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
|
|||||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
}
|
}
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
@@ -144,3 +241,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request)
|
|||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||||
|
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||||
|
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||||
|
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||||
|
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||||
|
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||||
|
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||||
|
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||||
|
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||||
|
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||||
|
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||||
|
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||||
|
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||||
|
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||||
|
betreiber := currentUser(r)
|
||||||
|
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||||
|
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ziel.Active {
|
||||||
|
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||||
|
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,11 +14,79 @@ import (
|
|||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
|
// werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
|
||||||
|
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
|
||||||
|
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
|
||||||
|
type werkzeugFilter struct {
|
||||||
|
Suche string
|
||||||
|
Zweck string
|
||||||
|
}
|
||||||
|
|
||||||
func istGueltigerVerarbeitungsort(v string) bool {
|
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
|
||||||
for _, o := range gueltigeVerarbeitungsorte {
|
return werkzeugFilter{
|
||||||
if o == v {
|
Suche: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||||
|
Zweck: r.URL.Query().Get("zweck"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
|
||||||
|
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
|
||||||
|
// nur exportierte Methoden per Reflection aufrufen kann.
|
||||||
|
func (f werkzeugFilter) Aktiv() bool {
|
||||||
|
return f.Suche != "" || f.Zweck != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
|
||||||
|
if f.Zweck != "" {
|
||||||
|
gefunden := false
|
||||||
|
for _, z := range wz.GeeigneteZwecke {
|
||||||
|
if z == f.Zweck {
|
||||||
|
gefunden = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Suche != "" {
|
||||||
|
q := strings.ToLower(f.Suche)
|
||||||
|
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
|
||||||
|
if !strings.Contains(heuhaufen, q) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
|
||||||
|
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
|
||||||
|
// nur für Verarbeitungsländer statt Zwecke.
|
||||||
|
func istGueltigesLand(v string) bool {
|
||||||
|
for _, l := range weltLaenderliste {
|
||||||
|
if l == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,14 +111,66 @@ type werkzeugListItem struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
Verarbeitungslaender string
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
Zertifizierungen string
|
||||||
|
GeeigneteZwecke string
|
||||||
LetztePruefung 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 {
|
type werkzeugListeData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Werkzeuge []werkzeugListItem
|
Werkzeuge []werkzeugListItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||||
@@ -60,11 +180,30 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
|
|||||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
for _, wz := range werkzeuge {
|
for _, wz := range werkzeuge {
|
||||||
|
if filter.matches(wz) {
|
||||||
|
gefiltert = append(gefiltert, wz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
seite := paginate(gefiltert, pag)
|
||||||
|
|
||||||
|
data := werkzeugListeData{
|
||||||
|
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||||
|
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||||
|
}
|
||||||
|
for _, wz := range seite {
|
||||||
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
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"),
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||||
@@ -83,23 +222,61 @@ type werkzeugFormData struct {
|
|||||||
ID string // leer bei "neu"
|
ID string // leer bei "neu"
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsorte []string
|
LaenderOptionen []string
|
||||||
Verarbeitungsort string
|
LaenderAusgewaehlt map[string]bool
|
||||||
|
DPFZertifiziert bool
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
AVVURL string
|
AVVURL string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
|
||||||
Zertifizierungen string
|
Zertifizierungen string
|
||||||
GeeigneteZwecke string
|
Subprozessoren string
|
||||||
|
ZweckeOptionen []string
|
||||||
|
ZweckeAusgewaehlt map[string]bool
|
||||||
Einschraenkungen string
|
Einschraenkungen string
|
||||||
LetztePruefung string
|
LetztePruefung string
|
||||||
Quelle string
|
Quelle string
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
|
||||||
|
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
||||||
|
func auswahlSet(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,
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
Name: wz.Name, Anbieter: wz.Anbieter,
|
||||||
|
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: auswahlSet(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) {
|
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
data := werkzeugFormData{
|
||||||
|
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -113,9 +290,14 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||||
}
|
}
|
||||||
verarbeitungsort := r.FormValue("verarbeitungsort")
|
var verarbeitungslaender []string
|
||||||
if !istGueltigerVerarbeitungsort(verarbeitungsort) {
|
for _, l := range r.Form["verarbeitungslaender"] {
|
||||||
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort"
|
if istGueltigesLand(l) {
|
||||||
|
verarbeitungslaender = append(verarbeitungslaender, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(verarbeitungslaender) == 0 {
|
||||||
|
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
|
||||||
}
|
}
|
||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
anbieter := r.FormValue("anbieter")
|
anbieter := r.FormValue("anbieter")
|
||||||
@@ -123,20 +305,36 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
|||||||
if name == "" || anbieter == "" || quelle == "" {
|
if name == "" || anbieter == "" || quelle == "" {
|
||||||
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
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{
|
return store.WerkzeugInput{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Anbieter: anbieter,
|
Anbieter: anbieter,
|
||||||
Verarbeitungsort: verarbeitungsort,
|
Verarbeitungslaender: verarbeitungslaender,
|
||||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||||
AVVURL: r.FormValue("avv_url"),
|
AVVURL: r.FormValue("avv_url"),
|
||||||
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
AufbewahrungTage: aufbewahrungTage,
|
AufbewahrungTage: aufbewahrungTage,
|
||||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||||
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
|
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||||
|
GeeigneteZwecke: geeigneteZwecke,
|
||||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||||
LetztePruefung: letztePruefung,
|
LetztePruefung: letztePruefung,
|
||||||
Quelle: quelle,
|
Quelle: quelle,
|
||||||
@@ -148,11 +346,15 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
|||||||
// statt das Formular zu leeren.
|
// statt das Formular zu leeren.
|
||||||
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||||
return werkzeugFormData{
|
return werkzeugFormData{
|
||||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
|
||||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
|
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
|
||||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||||
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
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: auswahlSet(r.Form["geeignete_zwecke"]),
|
||||||
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||||
}
|
}
|
||||||
@@ -192,15 +394,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
|
|||||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := werkzeugFormData{
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
|
||||||
Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", 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,
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ package web_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func werkzeugForm() url.Values {
|
func werkzeugForm() url.Values {
|
||||||
return url.Values{
|
return url.Values{
|
||||||
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"},
|
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||||
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||||
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||||
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||||
@@ -116,3 +118,71 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugListeFiltertNachSucheUndZweck(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
fs.werkzeuge["a"] = store.Werkzeug{
|
||||||
|
ID: "a", Name: "ChatGPT Enterprise", Anbieter: "OpenAI",
|
||||||
|
Verarbeitungslaender: []string{"USA"}, GeeigneteZwecke: []string{"Textgenerierung"},
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
fs.werkzeuge["b"] = store.Werkzeug{
|
||||||
|
ID: "b", Name: "DeepL Pro", Anbieter: "DeepL SE",
|
||||||
|
Verarbeitungslaender: []string{"Deutschland"}, GeeigneteZwecke: []string{"Übersetzung"},
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=deepl")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || !strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||||
|
t.Fatalf("q=deepl sollte nur DeepL Pro zeigen, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?zweck=Textgenerierung")
|
||||||
|
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||||
|
t.Fatalf("zweck=Textgenerierung sollte nur ChatGPT Enterprise zeigen, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=nichts-passt")
|
||||||
|
if !strings.Contains(resp.Body.String(), "Keine Treffer") {
|
||||||
|
t.Fatalf("erwartet 'Keine Treffer' bei erfolgloser Suche, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugListePaginiert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
id := fmt.Sprintf("wz-%02d", i)
|
||||||
|
fs.werkzeuge[id] = store.Werkzeug{
|
||||||
|
ID: id, Name: fmt.Sprintf("Werkzeug %02d", i), Anbieter: "Testanbieter",
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Seite 1 von 2") {
|
||||||
|
t.Fatalf("erwartet 'Seite 1 von 2' bei 25 Einträgen (20 pro Seite), body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Weiter") || strings.Contains(resp.Body.String(), "Zurück") {
|
||||||
|
t.Fatalf("Seite 1 sollte einen Weiter-, aber keinen Zurück-Button zeigen")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?page=2")
|
||||||
|
if !strings.Contains(resp.Body.String(), "Seite 2 von 2") {
|
||||||
|
t.Fatalf("erwartet 'Seite 2 von 2', body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Weiter") || !strings.Contains(resp.Body.String(), "Zurück") {
|
||||||
|
t.Fatalf("Seite 2 sollte einen Zurück-, aber keinen Weiter-Button zeigen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ type fallDetailData struct {
|
|||||||
Haeufigkeit string
|
Haeufigkeit string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
|
Antworten []antwortZeile
|
||||||
Bewertung *bewertungView
|
Bewertung *bewertungView
|
||||||
Entscheidung *entscheidungView
|
Entscheidung *entscheidungView
|
||||||
CanEntscheiden bool
|
CanEntscheiden bool
|
||||||
@@ -109,10 +111,15 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var antworten map[string]any
|
||||||
|
if len(antrag.Antworten) > 0 {
|
||||||
|
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||||
|
}
|
||||||
|
|
||||||
data := fallDetailData{
|
data := fallDetailData{
|
||||||
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"),
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
||||||
}
|
}
|
||||||
|
|
||||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
|||||||
@@ -87,6 +87,28 @@ func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
|
||||||
|
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
|
||||||
|
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
|
||||||
|
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
|
||||||
|
func TestFallDetailZeigtFragebogenAntworten(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||||
|
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||||
|
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
|
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
|
|||||||
@@ -13,13 +13,47 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
type indexData struct {
|
type indexData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
|
OffeneAntraege int
|
||||||
|
ZeigePosteingang bool
|
||||||
|
OffenerPosteingang int
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
|
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
|
||||||
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
|
// Überblick statt reiner Navigations-Duplikate (die Links stehen
|
||||||
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
|
// eh schon in der Seitenleiste): eigene offene Anträge, und für die
|
||||||
|
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
|
||||||
|
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
|
||||||
|
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
|
||||||
|
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
|
||||||
|
// bei jedem Login geladen wird, zu teuer.
|
||||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
data := indexData{Title: "Start", Nav: navFor(r)}
|
user := currentUser(r)
|
||||||
|
nav := navFor(r)
|
||||||
|
data := indexData{Title: "Start", Nav: nav}
|
||||||
|
|
||||||
|
if !nav.IsBetreiber {
|
||||||
|
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, a := range eigene {
|
||||||
|
if a.Status == "eingereicht" {
|
||||||
|
data.OffeneAntraege++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nav.IsFachebene {
|
||||||
|
data.ZeigePosteingang = true
|
||||||
|
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
|
||||||
|
if err == nil {
|
||||||
|
for _, a := range alle {
|
||||||
|
if a.Status == "eingereicht" {
|
||||||
|
data.OffenerPosteingang++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
65
internal/web/handlers_test.go
Normal file
65
internal/web/handlers_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, `href="/antraege"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Meine offenen Anträge") {
|
||||||
|
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, `href="/faelle"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Posteingang") {
|
||||||
|
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`) || !strings.Contains(resp.Body.String(), "Zur Plattform") {
|
||||||
|
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||||
|
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
120
internal/web/impersonation_test.go
Normal file
120
internal/web/impersonation_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("expected a new session cookie")
|
||||||
|
}
|
||||||
|
neueCookie := cookies[0]
|
||||||
|
|
||||||
|
// Die neue Sitzung ist tatsächlich der Kunden-Login.
|
||||||
|
protected := getWithCookie(t, s, neueCookie, "/antraege")
|
||||||
|
if protected.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
neueCookie := loginResp.Result().Cookies()[0]
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, neueCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
|
||||||
|
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
|
||||||
|
var gefunden bool
|
||||||
|
for _, e := range fs.auditLog {
|
||||||
|
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
|
||||||
|
gefunden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotLoginAls(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/web/laenderliste.go
Normal file
53
internal/web/laenderliste.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// weltLaenderliste ist die kontrollierte Auswahl für "Verarbeitungsländer"
|
||||||
|
// im Werkzeugkatalog — eine echte Länderliste statt grober Eimer wie
|
||||||
|
// "EU"/"USA"/"gemischt" (die USA sind DSGVO-rechtlich bereits ein
|
||||||
|
// Drittland wie jedes andere, siehe CLAUDE.md). Enthält zusätzlich zwei
|
||||||
|
// Pseudo-Einträge für Fälle, in denen der Anbieter selbst keinen
|
||||||
|
// einzelnen Staat zusichert bzw. keine Cloud-Verarbeitung stattfindet.
|
||||||
|
var weltLaenderliste = []string{
|
||||||
|
"EU-Region (kein einzelnes Land benannt)",
|
||||||
|
"On-Premise (selbst gehostet)",
|
||||||
|
|
||||||
|
"Afghanistan", "Ägypten", "Albanien", "Algerien", "Andorra", "Angola",
|
||||||
|
"Antigua und Barbuda", "Äquatorialguinea", "Argentinien", "Armenien",
|
||||||
|
"Aserbaidschan", "Äthiopien", "Australien", "Bahamas", "Bahrain",
|
||||||
|
"Bangladesch", "Barbados", "Belgien", "Belize", "Benin", "Bhutan",
|
||||||
|
"Bolivien", "Bosnien und Herzegowina", "Botsuana", "Brasilien",
|
||||||
|
"Brunei", "Bulgarien", "Burkina Faso", "Burundi", "Chile", "China",
|
||||||
|
"Costa Rica", "Dänemark", "Deutschland", "Dominica",
|
||||||
|
"Dominikanische Republik", "Dschibuti", "Ecuador", "El Salvador",
|
||||||
|
"Elfenbeinküste", "Eritrea", "Estland", "Eswatini", "Fidschi",
|
||||||
|
"Finnland", "Frankreich", "Gabun", "Gambia", "Georgien", "Ghana",
|
||||||
|
"Grenada", "Griechenland", "Guatemala", "Guinea", "Guinea-Bissau",
|
||||||
|
"Guyana", "Haiti", "Honduras", "Indien", "Indonesien", "Irak",
|
||||||
|
"Iran", "Irland", "Island", "Israel", "Italien", "Jamaika", "Japan",
|
||||||
|
"Jemen", "Jordanien", "Kambodscha", "Kamerun", "Kanada", "Kap Verde",
|
||||||
|
"Kasachstan", "Katar", "Kenia", "Kirgisistan", "Kiribati",
|
||||||
|
"Kolumbien", "Komoren", "Kongo (Republik)",
|
||||||
|
"Kongo (Demokratische Republik)", "Kosovo", "Kroatien", "Kuba",
|
||||||
|
"Kuwait", "Laos", "Lesotho", "Lettland", "Libanon", "Liberia",
|
||||||
|
"Libyen", "Liechtenstein", "Litauen", "Luxemburg", "Madagaskar",
|
||||||
|
"Malawi", "Malaysia", "Malediven", "Mali", "Malta", "Marokko",
|
||||||
|
"Marshallinseln", "Mauretanien", "Mauritius", "Mexiko",
|
||||||
|
"Mikronesien", "Moldau", "Monaco", "Mongolei", "Montenegro",
|
||||||
|
"Mosambik", "Myanmar", "Namibia", "Nauru", "Nepal", "Neuseeland",
|
||||||
|
"Nicaragua", "Niederlande", "Niger", "Nigeria", "Nordkorea",
|
||||||
|
"Nordmazedonien", "Norwegen", "Oman", "Österreich", "Osttimor",
|
||||||
|
"Pakistan", "Palau", "Palästina", "Panama", "Papua-Neuguinea",
|
||||||
|
"Paraguay", "Peru", "Philippinen", "Polen", "Portugal", "Ruanda",
|
||||||
|
"Rumänien", "Russland", "Salomonen", "Sambia", "Samoa",
|
||||||
|
"San Marino", "São Tomé und Príncipe", "Saudi-Arabien", "Schweden",
|
||||||
|
"Schweiz", "Senegal", "Serbien", "Seychellen", "Sierra Leone",
|
||||||
|
"Simbabwe", "Singapur", "Slowakei", "Slowenien", "Somalia",
|
||||||
|
"Spanien", "Sri Lanka", "St. Kitts und Nevis", "St. Lucia",
|
||||||
|
"St. Vincent und die Grenadinen", "Südafrika", "Sudan", "Südkorea",
|
||||||
|
"Südsudan", "Suriname", "Syrien", "Tadschikistan", "Taiwan",
|
||||||
|
"Tansania", "Thailand", "Togo", "Tonga", "Trinidad und Tobago",
|
||||||
|
"Tschad", "Tschechien", "Tunesien", "Türkei", "Turkmenistan",
|
||||||
|
"Tuvalu", "Uganda", "Ukraine", "Ungarn", "Uruguay", "USA",
|
||||||
|
"Usbekistan", "Vanuatu", "Vatikanstadt", "Venezuela",
|
||||||
|
"Vereinigte Arabische Emirate", "Vereinigtes Königreich", "Vietnam",
|
||||||
|
"Zentralafrikanische Republik", "Zypern",
|
||||||
|
}
|
||||||
@@ -11,12 +11,22 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mandantWerkzeugItem struct {
|
type mandantWerkzeugItem struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
GeeigneteZwecke string
|
||||||
|
LetztePruefung string
|
||||||
|
PruefStatusLabel string
|
||||||
|
PruefStatusCSS string
|
||||||
Gesperrt bool
|
Gesperrt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +34,17 @@ type eigenesWerkzeugItem struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
LetztePruefung string
|
||||||
}
|
}
|
||||||
|
|
||||||
type mandantWerkzeugListeData struct {
|
type mandantWerkzeugListeData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Werkzeuge []mandantWerkzeugItem
|
Werkzeuge []mandantWerkzeugItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
EigeneWerkzeuge []eigenesWerkzeugItem
|
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,17 +74,40 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
|||||||
gesperrt[sp.WerkzeugID] = true
|
gesperrt[sp.WerkzeugID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
for _, wz := range zentral {
|
for _, wz := range zentral {
|
||||||
|
if filter.matches(wz) {
|
||||||
|
gefiltert = append(gefiltert, wz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
seite := paginate(gefiltert, pag)
|
||||||
|
|
||||||
|
data := mandantWerkzeugListeData{
|
||||||
|
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||||
|
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||||
|
}
|
||||||
|
for _, wz := range seite {
|
||||||
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
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, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
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 {
|
for _, wz := range eigene {
|
||||||
if wz.AccountID == nil {
|
if wz.AccountID == nil {
|
||||||
continue // zentral, oben schon gelistet
|
continue // zentral, oben schon gelistet
|
||||||
}
|
}
|
||||||
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter})
|
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
@@ -77,7 +115,11 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
data := werkzeugFormData{
|
||||||
|
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -121,15 +163,7 @@ func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Re
|
|||||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := werkzeugFormData{
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
|
||||||
Title: "Eigenes Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", 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,
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,44 +12,67 @@ const sessionCookieName = "deklarix_session"
|
|||||||
|
|
||||||
type contextKey int
|
type contextKey int
|
||||||
|
|
||||||
const userContextKey contextKey = iota
|
const (
|
||||||
|
userContextKey contextKey = iota
|
||||||
|
impersonatorContextKey
|
||||||
|
)
|
||||||
|
|
||||||
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
|
||||||
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||||
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
|
||||||
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
|
||||||
|
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
|
||||||
|
// sonst leer.
|
||||||
|
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
if time.Now().After(sess.ExpiresAt) {
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||||
if err != nil || !user.Active {
|
if err != nil || !user.Active {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
return user, true
|
|
||||||
|
impersonator := ""
|
||||||
|
if sess.ImpersonatedByUserID != nil {
|
||||||
|
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
|
||||||
|
impersonator = betreiber.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, impersonator, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
|
||||||
|
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
|
||||||
|
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
if impersonator != "" {
|
||||||
|
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||||
|
}
|
||||||
|
return r.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +82,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||||
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +99,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||||
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -85,7 +108,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +129,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// nicht erfährt, dass es die Seite überhaupt gibt.
|
// nicht erfährt, dass es die Seite überhaupt gibt.
|
||||||
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -115,7 +138,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +147,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -133,7 +156,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +168,7 @@ type navData struct {
|
|||||||
IsBetreiber bool
|
IsBetreiber bool
|
||||||
IsFachebene bool
|
IsFachebene bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
|
||||||
}
|
}
|
||||||
|
|
||||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||||
@@ -157,6 +181,7 @@ func navFor(r *http.Request) navData {
|
|||||||
// admin sieht die Fachebene mit, siehe requireFachebene.
|
// admin sieht die Fachebene mit, siehe requireFachebene.
|
||||||
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||||
IsAdmin: role == "admin",
|
IsAdmin: role == "admin",
|
||||||
|
Impersonator: currentImpersonator(r),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,3 +196,10 @@ func currentUser(r *http.Request) store.User {
|
|||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentImpersonator liefert die E-Mail des Betreibers, falls die
|
||||||
|
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
|
||||||
|
func currentImpersonator(r *http.Request) string {
|
||||||
|
v, _ := r.Context().Value(impersonatorContextKey).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
75
internal/web/pagination.go
Normal file
75
internal/web/pagination.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// pagination.go — wiederverwendbare serverseitige Paginierung für
|
||||||
|
// Listen-Seiten (Werkzeugkatalog, Register, ...). Bewusst ohne
|
||||||
|
// JavaScript: Seiten-/Filterwechsel läuft über ein einziges GET-Formular,
|
||||||
|
// das Query-Parameter (q, zweck, page, ...) setzt — siehe CLAUDE.md,
|
||||||
|
// Frontend.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultPerPage = 20
|
||||||
|
|
||||||
|
// pagination beschreibt die aktuelle Seite einer Liste. Templates lesen
|
||||||
|
// daraus Seitenzahl, Gesamtzahl und ob Vor/Zurück-Buttons sichtbar sein
|
||||||
|
// sollen.
|
||||||
|
type pagination struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Total int
|
||||||
|
TotalPages int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginationFromRequest liest "page" aus der Query (Standard: 1, nie
|
||||||
|
// kleiner 1, nie größer als die letzte tatsächlich vorhandene Seite).
|
||||||
|
// per_page ist bewusst nicht vom Nutzer einstellbar (kein Query-Param
|
||||||
|
// dafür) — eine feste Seitengröße hält die Filter-URLs einfach und
|
||||||
|
// verhindert versehentlich riesige Anfragen.
|
||||||
|
func paginationFromRequest(r *http.Request, total int) pagination {
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage := defaultPerPage
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
return pagination{
|
||||||
|
Page: page, PerPage: perPage, Total: total, TotalPages: totalPages,
|
||||||
|
HasPrev: page > 1, HasNext: page < totalPages,
|
||||||
|
PrevPage: page - 1, NextPage: page + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounds liefert den Start-/End-Index für die aktuelle Seite innerhalb
|
||||||
|
// der (bereits gefilterten) Gesamtliste.
|
||||||
|
func (p pagination) bounds() (start, end int) {
|
||||||
|
start = (p.Page - 1) * p.PerPage
|
||||||
|
end = start + p.PerPage
|
||||||
|
if end > p.Total {
|
||||||
|
end = p.Total
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginate schneidet den Ausschnitt für die aktuelle Seite aus einer
|
||||||
|
// bereits gefilterten/sortierten Liste heraus — generisch, damit jede
|
||||||
|
// Listen-Seite (Werkzeuge, Register, ...) dieselbe Funktion nutzt statt
|
||||||
|
// eigene Slice-Arithmetik zu wiederholen.
|
||||||
|
func paginate[T any](items []T, p pagination) []T {
|
||||||
|
start, end := p.bounds()
|
||||||
|
return items[start:end]
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ var staticFS embed.FS
|
|||||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
|
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||||
@@ -39,6 +40,7 @@ type Store interface {
|
|||||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||||
SetUserActive(ctx context.Context, id string, active bool) error
|
SetUserActive(ctx context.Context, id string, active bool) error
|
||||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||||
|
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||||
DeleteSession(ctx context.Context, token string) error
|
DeleteSession(ctx context.Context, token string) error
|
||||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
@@ -151,7 +153,11 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
|
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
|
||||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
||||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||||
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||||
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
|
|||||||
return acc, nil
|
return acc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
acc.Name = name
|
||||||
|
f.accounts[id] = acc
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -180,6 +192,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
|
|||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||||
|
f.sessions[token] = sess
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -388,9 +408,9 @@ func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput)
|
|||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
w := store.Werkzeug{
|
w := store.Werkzeug{
|
||||||
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
||||||
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
||||||
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage,
|
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
|
||||||
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
||||||
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
f.werkzeuge[w.ID] = w
|
f.werkzeuge[w.ID] = w
|
||||||
@@ -404,10 +424,10 @@ func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.Werk
|
|||||||
if !ok {
|
if !ok {
|
||||||
return store.Werkzeug{}, store.ErrNotFound
|
return store.Werkzeug{}, store.ErrNotFound
|
||||||
}
|
}
|
||||||
w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort
|
w.Name, w.Anbieter, w.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
|
||||||
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
||||||
w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage
|
w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
|
||||||
w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen
|
w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen
|
||||||
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
|
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
|
||||||
f.werkzeuge[id] = w
|
f.werkzeuge[id] = w
|
||||||
return w, nil
|
return w, nil
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ h1, h2, h3 {
|
|||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.0625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -275,6 +283,38 @@ nav button {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
|
||||||
|
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
|
||||||
|
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
|
||||||
|
.impersonation-banner {
|
||||||
|
background: var(--color-mittel-bg);
|
||||||
|
color: var(--color-mittel);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-mittel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.impersonation-banner {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fehler {
|
.fehler {
|
||||||
background: var(--color-hoch-bg);
|
background: var(--color-hoch-bg);
|
||||||
color: var(--color-hoch);
|
color: var(--color-hoch);
|
||||||
@@ -360,6 +400,16 @@ nav button {
|
|||||||
color: var(--color-mittel);
|
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 {
|
.beitraege-liste {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
@@ -500,8 +550,8 @@ button.entfernen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fragebogen legend {
|
.fragebogen legend {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
font-size: 0.9375rem;
|
font-size: 1rem;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +563,130 @@ button.entfernen {
|
|||||||
margin: 4px 12px 4px 0;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* <legend> ignoriert das padding des umschließenden <fieldset> (es
|
||||||
|
positioniert sich an dessen Border-Box, nicht an der Padding-Box) —
|
||||||
|
ohne eigenes padding-left säße die Abschnittsüberschrift bündig am
|
||||||
|
Kartenrand, während der restliche Inhalt eingerückt ist. */
|
||||||
|
.form-section > legend {
|
||||||
|
padding: 0 0 16px 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
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"] {
|
.fragebogen fieldset input[type="radio"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -563,11 +737,236 @@ tbody tr:hover {
|
|||||||
background: var(--color-bg);
|
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 {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 16px;
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter-/Paginierungsleiste für Listen-Seiten (Werkzeugkatalog,
|
||||||
|
Register, ...) — ein einziges GET-Formular ohne JavaScript, siehe
|
||||||
|
pagination.go. */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar input[type="search"] {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar select {
|
||||||
|
flex: 0 1 200px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive Tabellen — Card-Ansicht auf dem Handy ────────────────
|
||||||
|
enconf-Pattern (ProTable.tsx): auf schmalen Screens wird nicht
|
||||||
|
horizontal gescrollt, sondern jede Zeile zu einer eigenen Karte mit
|
||||||
|
Label/Wert-Paaren. Braucht data-label="Spaltenname" auf jedem <td>.
|
||||||
|
Ab 640px bleibt es eine normale Tabelle (mit Scroll-Fade als
|
||||||
|
Sicherheitsnetz bei vielen Spalten). */
|
||||||
|
.table-responsive {
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: visible;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive table,
|
||||||
|
.table-responsive tbody,
|
||||||
|
.table-responsive tr,
|
||||||
|
.table-responsive td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive thead {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive tbody tr {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive td {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: right;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive td:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
text-align: left;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Seitenkopf (PageHeader-Pattern, enconf) ─────────────────────────
|
||||||
|
Titel + Unterzeile links, primäre Aktion rechts — statt eines
|
||||||
|
nackten <h1> mit einem Link irgendwo unten auf der Seite. */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .hinweis {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .page-header-action {
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--branding-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .page-header-action:hover {
|
||||||
|
filter: brightness(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kennzahl-Kacheln (StatCard-Pattern, enconf Dashboard) ───────────
|
||||||
|
Vorher: einfache Text-Links in einer Liste ohne visuelle Priorität.
|
||||||
|
Jetzt: farbiger oberer Akzentrand + große Zahl + Klick navigiert zur
|
||||||
|
Liste, wie enconfs StatCard-Komponente — nur ohne Icon-Bibliothek. */
|
||||||
|
.stat-cards {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-top: 3px solid var(--branding-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 16px 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-heading);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-mittel {
|
||||||
|
border-top-color: var(--color-mittel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-hoch {
|
||||||
|
border-top-color: var(--color-hoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-action {
|
||||||
|
background: var(--branding-primary);
|
||||||
|
border-color: var(--branding-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-action .stat-card-label {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||||
|
|
||||||
|
{{if .Antworten}}
|
||||||
|
<h2>Antworten im Fragebogen</h2>
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Antworten}}
|
||||||
|
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Bewertung}}
|
{{if .Bewertung}}
|
||||||
{{template "bewertung-block" .Bewertung}}
|
{{template "bewertung-block" .Bewertung}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -8,11 +8,25 @@
|
|||||||
<h1>{{.Name}}</h1>
|
<h1>{{.Name}}</h1>
|
||||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||||
|
|
||||||
|
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
|
||||||
|
<label for="name">Firmenname</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||||
|
<button type="submit">Umbenennen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<h2>Nutzer</h2>
|
<h2>Nutzer</h2>
|
||||||
<ul class="beteiligte">
|
<ul class="beteiligte">
|
||||||
{{range .Users}}
|
{{range .Users}}
|
||||||
<li class="beteiligter">
|
<li class="beteiligter">
|
||||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
<div class="beteiligter-kopf">
|
||||||
|
{{.Email}} <span class="rolle">({{.Role}})</span>
|
||||||
|
{{if not .Active}}<span class="status">deaktiviert</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Active}}
|
||||||
|
<form method="post" action="/betreiber/accounts/{{$.AccountID}}/nutzer/{{.ID}}/anmelden-als" onsubmit="return confirm('Als {{.Email}} anmelden? Ihre eigene Sitzung wird dabei ersetzt.')">
|
||||||
|
<button type="submit">Als Nutzer anmelden</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
29
internal/web/templates/betreiber_account_neu.html
Normal file
29
internal/web/templates/betreiber_account_neu.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "betreiber-account-neu"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/betreiber/accounts">← Accounts</a></p>
|
||||||
|
<h1>Firma anlegen</h1>
|
||||||
|
<p class="hinweis">
|
||||||
|
Legt eine Firma mit dem ersten Login (Rolle admin) an — dasselbe
|
||||||
|
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
|
||||||
|
</p>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/betreiber/accounts">
|
||||||
|
<label for="account_name">Firmenname</label>
|
||||||
|
<input type="text" id="account_name" name="account_name" required>
|
||||||
|
|
||||||
|
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
|
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
||||||
|
<input type="password" id="password" name="password" minlength="8" required>
|
||||||
|
|
||||||
|
<button type="submit">Firma anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,59 +7,113 @@
|
|||||||
<p><a href="{{.ActionBase}}">← Werkzeugkatalog</a></p>
|
<p><a href="{{.ActionBase}}">← Werkzeugkatalog</a></p>
|
||||||
<h1>{{.Title}}</h1>
|
<h1>{{.Title}}</h1>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out,
|
Jede Zusicherung hier (AVV, Verarbeitungsländer, Training-Opt-out,
|
||||||
Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind
|
Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind
|
||||||
Pflicht.
|
Pflicht.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
<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>
|
<label for="name">Name</label>
|
||||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label for="anbieter">Anbieter</label>
|
<label for="anbieter">Anbieter</label>
|
||||||
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
|
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
|
||||||
|
</div>
|
||||||
<fieldset>
|
<div class="form-full">
|
||||||
<legend>Verarbeitungsort</legend>
|
<label for="verarbeitungslaender">Verarbeitungsländer (Mehrfachauswahl: Strg/Cmd-Klick)</label>
|
||||||
{{$gewaehlt := .Verarbeitungsort}}
|
<select id="verarbeitungslaender" name="verarbeitungslaender" multiple size="8" required>
|
||||||
{{range .Verarbeitungsorte}}
|
{{range .LaenderOptionen}}
|
||||||
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
|
<option value="{{.}}" {{if index $.LaenderAusgewaehlt .}}selected{{end}}>{{.}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<p class="hinweis">Tatsächliche Länder benennen, nicht "EU"/"Drittland" — die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Rechtliches & 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="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}}">
|
|
||||||
|
|
||||||
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</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>
|
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
|
||||||
|
<label><input type="checkbox" name="dpf_zertifiziert" {{if .DPFZertifiziert}}checked{{end}}> EU-US Data Privacy Framework (DPF) zertifiziert</label>
|
||||||
<label for="aufbewahrung_tage">Aufbewahrung (Tage)</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}}">
|
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
||||||
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
<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 for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
|
<fieldset class="form-section">
|
||||||
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
|
<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>
|
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
||||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Beleg</legend>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
<label for="letzte_pruefung">Letzte Prüfung</label>
|
<label for="letzte_pruefung">Letzte Prüfung</label>
|
||||||
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label for="quelle">Quelle (Beleg)</label>
|
<label for="quelle">Quelle (Beleg)</label>
|
||||||
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{if .ID}}
|
{{if .ID}}
|
||||||
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
<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>
|
<button type="submit" class="entfernen">Werkzeug löschen</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,21 +5,66 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber">← Plattform</a></p>
|
<p><a href="/betreiber">← Plattform</a></p>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h1>Werkzeugkatalog</h1>
|
<h1>Werkzeugkatalog</h1>
|
||||||
|
<p class="hinweis">Zentral gepflegt, für alle Mandanten sichtbar.</p>
|
||||||
|
</div>
|
||||||
|
<a class="page-header-action" href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a>
|
||||||
|
</div>
|
||||||
|
<form method="get">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
|
||||||
|
<select name="zweck">
|
||||||
|
<option value="">Alle Zwecke</option>
|
||||||
|
{{$zweck := .Filter.Zweck}}
|
||||||
|
{{range .ZweckeOptionen}}
|
||||||
|
<option value="{{.}}" {{if eq . $zweck}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
{{if .Filter.Aktiv}}<a href="/betreiber/werkzeuge">Zurücksetzen</a>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if not .Werkzeuge}}
|
{{if not .Werkzeuge}}
|
||||||
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
|
<p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Noch keine zentralen Katalogeinträge.{{end}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="beitraege-liste">
|
<div class="table-responsive">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</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}}
|
{{range .Werkzeuge}}
|
||||||
<li>
|
<tr>
|
||||||
<a href="/betreiber/werkzeuge/{{.ID}}">
|
<td data-label="Name"><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||||
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
|
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||||
</a>
|
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||||
</li>
|
<td data-label="DPF">{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
|
||||||
|
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||||
|
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||||
|
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
|
||||||
|
<td data-label="Zertifizierungen">{{.Zertifizierungen}}</td>
|
||||||
|
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
|
||||||
|
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||||
|
<span>
|
||||||
|
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">← Zurück</button>{{end}}
|
||||||
|
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||||
|
|
||||||
|
{{if .Antworten}}
|
||||||
|
<h2>Antworten im Fragebogen</h2>
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Antworten}}
|
||||||
|
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Bewertung}}
|
{{if .Bewertung}}
|
||||||
{{template "bewertung-block" .Bewertung}}
|
{{template "bewertung-block" .Bewertung}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -5,10 +5,29 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Willkommen bei Deklarix</h1>
|
<h1>Willkommen bei Deklarix</h1>
|
||||||
<ul class="admin-kacheln">
|
{{if .Nav.IsBetreiber}}
|
||||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
<ul class="stat-cards">
|
||||||
<li><a href="/antraege">Meine Anträge</a></li>
|
<li><a class="stat-card stat-card-action" href="/betreiber"><span class="stat-card-label">Zur Plattform →</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<ul class="stat-cards">
|
||||||
|
<li><a class="stat-card stat-card-action" href="/antraege/neu"><span class="stat-card-label">Antrag stellen →</span></a></li>
|
||||||
|
<li>
|
||||||
|
<a class="stat-card{{if .OffeneAntraege}} stat-card-mittel{{end}}" href="/antraege">
|
||||||
|
<span class="stat-card-value">{{.OffeneAntraege}}</span>
|
||||||
|
<span class="stat-card-label">Meine offenen Anträge</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{if .ZeigePosteingang}}
|
||||||
|
<li>
|
||||||
|
<a class="stat-card{{if .OffenerPosteingang}} stat-card-hoch{{end}}" href="/faelle">
|
||||||
|
<span class="stat-card-value">{{.OffenerPosteingang}}</span>
|
||||||
|
<span class="stat-card-label">Posteingang</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -37,6 +37,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
{{if .Impersonator}}
|
||||||
|
<div class="impersonation-banner">
|
||||||
|
Support-Zugriff durch <strong>{{.Impersonator}}</strong> — dies ist nicht Ihre eigene Sitzung.
|
||||||
|
<form method="post" action="/logout" style="display:inline">
|
||||||
|
<button type="submit">Sitzung beenden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||||
|
|||||||
@@ -6,19 +6,51 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Werkzeugkatalog</h1>
|
<h1>Werkzeugkatalog</h1>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h2>Zentraler Katalog</h2>
|
<h2>Zentraler Katalog</h2>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
||||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
zentralen Katalog unverändert — es wird nur für diesen Mandanten
|
||||||
der Werkzeugsuche ausgeblendet.
|
aus der Werkzeugsuche ausgeblendet.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="get" class="filter-bar">
|
||||||
|
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
|
||||||
|
<select name="zweck">
|
||||||
|
<option value="">Alle Zwecke</option>
|
||||||
|
{{$zweck := .Filter.Zweck}}
|
||||||
|
{{range .ZweckeOptionen}}
|
||||||
|
<option value="{{.}}" {{if eq . $zweck}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
{{if .Filter.Aktiv}}<a href="/verwaltung/werkzeuge">Zurücksetzen</a>{{end}}
|
||||||
|
</form>
|
||||||
{{if not .Werkzeuge}}
|
{{if not .Werkzeuge}}
|
||||||
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
<p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Der zentrale Katalog ist noch leer.{{end}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="beitraege-liste">
|
<div class="table-responsive">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</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}}
|
{{range .Werkzeuge}}
|
||||||
<li>
|
<tr>
|
||||||
{{.Name}} · {{.Anbieter}}
|
<td data-label="Name">{{.Name}}</td>
|
||||||
|
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||||
|
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||||
|
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||||
|
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||||
|
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
|
||||||
|
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
|
||||||
|
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||||
|
<td data-label="Status">
|
||||||
{{if .Gesperrt}}
|
{{if .Gesperrt}}
|
||||||
<span class="status">gesperrt</span>
|
<span class="status">gesperrt</span>
|
||||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||||
@@ -30,28 +62,66 @@
|
|||||||
<button type="submit">Sperren</button>
|
<button type="submit">Sperren</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</li>
|
</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||||
|
<span>
|
||||||
|
{{if .Pagination.HasPrev}}
|
||||||
|
<form method="get" style="display:inline">
|
||||||
|
<input type="hidden" name="q" value="{{.Filter.Suche}}">
|
||||||
|
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
|
||||||
|
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
|
||||||
|
<button type="submit">← Zurück</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if .Pagination.HasNext}}
|
||||||
|
<form method="get" style="display:inline">
|
||||||
|
<input type="hidden" name="q" value="{{.Filter.Suche}}">
|
||||||
|
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
|
||||||
|
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
|
||||||
|
<button type="submit">Weiter →</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h2>Eigene Werkzeuge</h2>
|
<h2>Eigene Werkzeuge</h2>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
||||||
hier, nicht im zentralen Katalog der Plattform.
|
hier, nicht im zentralen Katalog der Plattform.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<a class="page-header-action" href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a>
|
||||||
|
</div>
|
||||||
{{if not .EigeneWerkzeuge}}
|
{{if not .EigeneWerkzeuge}}
|
||||||
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="beitraege-liste">
|
<div class="table-responsive">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{{range .EigeneWerkzeuge}}
|
{{range .EigeneWerkzeuge}}
|
||||||
<li>
|
<tr>
|
||||||
<a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}} · {{.Anbieter}}</a>
|
<td data-label="Name"><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||||
</li>
|
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||||
|
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||||
|
<td data-label="Zuletzt geprüft">{{.LetztePruefung}}</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ package web
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
@@ -30,8 +32,9 @@ func werkzeugDiff(snapshot, current store.Werkzeug) []string {
|
|||||||
if snapshot.TrainingStandard != current.TrainingStandard {
|
if snapshot.TrainingStandard != current.TrainingStandard {
|
||||||
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
|
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
|
||||||
}
|
}
|
||||||
if snapshot.Verarbeitungsort != current.Verarbeitungsort {
|
if !slices.Equal(snapshot.Verarbeitungslaender, current.Verarbeitungslaender) {
|
||||||
diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort))
|
diffs = append(diffs, fmt.Sprintf("Verarbeitungsländer: %s → %s",
|
||||||
|
strings.Join(snapshot.Verarbeitungslaender, ", "), strings.Join(current.Verarbeitungslaender, ", ")))
|
||||||
}
|
}
|
||||||
return diffs
|
return diffs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ bewusst nicht geraten, sondern hier notiert — vor Phase 3 (Ableitungen
|
|||||||
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
|
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
|
||||||
betrieblichen Datenschutzbeauftragten) klären.
|
betrieblichen Datenschutzbeauftragten) klären.
|
||||||
|
|
||||||
## 1. Rangfolge der Datenklassen
|
## 1. Rangfolge der Datenklassen — BESTÄTIGT (2026-08-29)
|
||||||
|
|
||||||
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
|
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
|
||||||
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
|
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt diese
|
||||||
diese Reihenfolge an (niedrigster zu höchstem Rang):
|
Reihenfolge an (niedrigster zu höchstem Rang) — **vom Produktverantwortlichen
|
||||||
|
bestätigt, keine Änderung nötig:**
|
||||||
|
|
||||||
1. `oeffentlich`
|
1. `oeffentlich`
|
||||||
2. `intern`
|
2. `intern`
|
||||||
@@ -18,22 +19,22 @@ diese Reihenfolge an (niedrigster zu höchstem Rang):
|
|||||||
5. `berufsgeheimnis`
|
5. `berufsgeheimnis`
|
||||||
6. `besondere_kategorie`
|
6. `besondere_kategorie`
|
||||||
|
|
||||||
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
|
Begründung: DSGVO Art. 9 (besondere Kategorien) gilt allgemein als
|
||||||
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
|
striktester Datenschutz-Tatbestand, § 203 StGB (Berufsgeheimnis) hat
|
||||||
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
|
eigene strafrechtliche Relevanz — beide vor "normalen" personenbezogenen
|
||||||
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
|
Daten eingeordnet. `auftragsdaten` unter `personenbezogen` einsortiert,
|
||||||
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
|
weil vertragliche Geheimhaltung in der Regel schwächer sanktioniert ist
|
||||||
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
|
als DSGVO-Bußgelder.
|
||||||
bestätigt.**
|
|
||||||
|
|
||||||
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
|
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten` — BESTÄTIGT (2026-08-29)
|
||||||
|
|
||||||
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
|
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
|
||||||
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
||||||
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
|
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
|
||||||
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
|
`rules/anforderungen.yaml` schließt `auftragsdaten` ein (Kundendaten
|
||||||
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
sollten aus denselben Gründen wie Geschäftsgeheimnisse nicht zum
|
||||||
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
|
Training verwendet werden) — **vom Produktverantwortlichen bestätigt,
|
||||||
|
keine Änderung nötig.**
|
||||||
|
|
||||||
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
||||||
|
|
||||||
@@ -43,9 +44,9 @@ jetzt eine Folgefrage `c3_art` mit den Werten
|
|||||||
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||||
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
||||||
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
||||||
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
|
final, nicht mehr Platzhalter. Ob diese drei Varianten fachlich
|
||||||
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
|
vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
|
||||||
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
|
**bestätigt (keine Ergänzung nötig).**
|
||||||
|
|
||||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||||
|
|
||||||
@@ -60,15 +61,15 @@ wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
|||||||
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
||||||
muss die Filterfunktion entsprechend erweitert werden.
|
muss die Filterfunktion entsprechend erweitert werden.
|
||||||
|
|
||||||
## 5. Fallback, wenn keine Datenklasse zutrifft
|
## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
|
||||||
|
|
||||||
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
||||||
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
||||||
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
||||||
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
||||||
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
||||||
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
|
Zweifel mehr Schutzanforderungen, nicht weniger. **Vom
|
||||||
bestätigt**, nur eine sichere Standardannahme.
|
Produktverantwortlichen bestätigt, keine Änderung nötig.**
|
||||||
|
|
||||||
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user