Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e1bcc8f7 | ||
|
|
2e459f1dd1 | ||
|
|
d77b19f61f | ||
|
|
dbeaa45644 | ||
|
|
4358dbaa0d | ||
|
|
7397f70068 | ||
|
|
2736e2c0db | ||
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 |
276
CLAUDE.md
276
CLAUDE.md
@@ -39,8 +39,8 @@ Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
|
||||
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
||||
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
||||
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
||||
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
|
||||
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
|
||||
Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
|
||||
erkennen, siehe `gueltigeZwecke` weiter unten), nie für die Einstufung
|
||||
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
||||
|
||||
---
|
||||
@@ -67,7 +67,10 @@ Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
|
||||
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
|
||||
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
||||
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
|
||||
`admin` hieß.
|
||||
`admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
|
||||
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
|
||||
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
|
||||
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
|
||||
|
||||
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||
@@ -112,11 +115,46 @@ reiner Anzeige-Screen reicht nicht):
|
||||
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
||||
erreichbar (404).
|
||||
|
||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
|
||||
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
|
||||
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
|
||||
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
|
||||
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
|
||||
**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
|
||||
`GET /betreiber/accounts/{id}` kann sich der Betreiber über
|
||||
`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
|
||||
anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
|
||||
`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
|
||||
0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt
|
||||
die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum
|
||||
Betreiber" — der Betreiber meldet sich danach mit den eigenen
|
||||
Zugangsdaten neu an). Jede Nutzung erzeugt einen `audit_log`-Eintrag
|
||||
(Action `betreiber_login_als_nutzer`); während der gesamten Sitzung
|
||||
zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
|
||||
siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist,
|
||||
im Kontext eines fremden Kontos zu handeln.
|
||||
|
||||
**Firmen-CRUD für den Betreiber ist umgesetzt:** `GET/POST
|
||||
/betreiber/accounts/neu` legt eine Firma samt erstem `admin`-Login an
|
||||
(dasselbe Ergebnis wie die öffentliche Registrierung, nur vom Betreiber
|
||||
ausgelöst — z. B. für vertriebsunterstütztes Onboarding oder Testkonten),
|
||||
`POST /betreiber/accounts/{id}/umbenennen` korrigiert den Firmennamen
|
||||
(`store.UpdateAccount`). Jede Firmenanlage erzeugt einen
|
||||
`audit_log`-Eintrag. Bewusst **kein** Löschen — ein Hard-Delete würde
|
||||
gegen die Fremdschlüssel aus `antrag`/`app_user`/`audit_log` laufen und
|
||||
Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
|
||||
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
|
||||
noch nicht, siehe unten).
|
||||
|
||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, das
|
||||
Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche
|
||||
Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes,
|
||||
externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**.
|
||||
Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte
|
||||
Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum),
|
||||
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
|
||||
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
|
||||
Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst
|
||||
(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage
|
||||
Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der
|
||||
Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts
|
||||
durch den Betreiber hängt weiter an dieser (jetzt kleineren)
|
||||
Subskriptions-Architektur (siehe Offene Punkte: `account.verified`
|
||||
wurde beim Produktwechsel sogar entfernt).
|
||||
|
||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||
@@ -213,11 +251,106 @@ Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
|
||||
zentralen Katalog zu verändern.
|
||||
|
||||
**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
|
||||
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
|
||||
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
|
||||
nicht in der UI umgesetzt, siehe Offene Punkte).
|
||||
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
|
||||
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
|
||||
`betreiber_werkzeug_handlers.go`, CSS-Klassen `status-niedrig`/
|
||||
`-mittel`/`-hoch`, dieselben Farbtoken wie die KI-VO-Risikoampel):
|
||||
**aktuell** (grün, < 150 Tage), **bald fällig** (gelb, 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.
|
||||
|
||||
Der zentrale Katalog ist aktuell mit 19 recherchierten Einträgen
|
||||
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
|
||||
Work, GitHub Copilot Business, DeepL Pro/API, Google Gemini für Google
|
||||
Workspace, Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly
|
||||
Enterprise, Otter.ai Business, 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. 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.
|
||||
|
||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||
@@ -234,8 +367,8 @@ bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
|
||||
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
||||
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
||||
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
||||
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
|
||||
und gegen `geeignete_zwecke` filtern.
|
||||
(die zwölf Kategorien aus `gueltigeZwecke`, siehe Werkzeugkatalog
|
||||
weiter unten) und gegen `geeignete_zwecke` filtern.
|
||||
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
||||
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
||||
Prüfung.
|
||||
@@ -293,7 +426,7 @@ wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
|
||||
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
||||
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
||||
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
|
||||
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
|
||||
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
|
||||
@@ -334,10 +467,19 @@ jederzeit erneuerbar [macht den alten Link sofort ungültig].
|
||||
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
||||
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
||||
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
||||
Einladung.), (2) CSV-Import, (3) SSO (Entra ID/Google Workspace),
|
||||
(4) Antrag ganz ohne vorheriges Konto über Mandantenlink + E-Mail-
|
||||
Bestätigungscode (Konto entsteht nebenbei). (2)-(4) sind weiterhin
|
||||
nicht gebaut.
|
||||
Einladung.), (2) ~~CSV-Import~~ — **erledigt** (`internal/web/csv_import_handlers.go`,
|
||||
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
|
||||
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
|
||||
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
|
||||
ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`],
|
||||
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
|
||||
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
|
||||
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
|
||||
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
|
||||
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
|
||||
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
|
||||
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
|
||||
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
|
||||
|
||||
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
|
||||
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
||||
@@ -373,7 +515,56 @@ kein separates Desktop-Template.
|
||||
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
||||
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
||||
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
||||
Scrollen auf dem Handy statt gequetschter Spalten).
|
||||
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.
|
||||
|
||||
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||
@@ -722,16 +913,24 @@ journalctl -u deklarix -f
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
||||
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
|
||||
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
|
||||
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
|
||||
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
|
||||
bleiben, aber die Ableitung kann sich noch ändern.
|
||||
- ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
|
||||
2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
|
||||
nötig.
|
||||
- ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
|
||||
(`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
|
||||
eingeschlossen.
|
||||
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
|
||||
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
|
||||
(siehe `rules/OPEN.md`, Punkt 3).
|
||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
|
||||
die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
|
||||
ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
|
||||
- ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
|
||||
— **bestätigt**, `intern` bleibt die konservative Standardannahme.
|
||||
- **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
|
||||
(`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
|
||||
`kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
|
||||
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
||||
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
||||
Rückmeldung dazu bisher eingeholt.
|
||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||
@@ -741,11 +940,20 @@ journalctl -u deklarix -f
|
||||
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
||||
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
||||
entstehen, die sonst nachträglich migriert werden müssten.
|
||||
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die
|
||||
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
|
||||
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
|
||||
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
|
||||
zwei `app_user`-Zeilen für dieselbe Person?
|
||||
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
|
||||
dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
|
||||
Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
|
||||
`fachebene_handlers.go`). Grund: ohne das könnte eine frisch
|
||||
registrierte Firma mit nur einem `admin`-Login keinen einzigen
|
||||
eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
|
||||
Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
|
||||
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
|
||||
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
|
||||
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
|
||||
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
|
||||
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
|
||||
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
||||
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
||||
|
||||
@@ -186,11 +186,47 @@ func containsString(list []string, v string) bool {
|
||||
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
||||
// unabhängig von internal/store und für sich allein testbar.
|
||||
type WerkzeugEigenschaften struct {
|
||||
AVVVerfuegbar bool
|
||||
Verarbeitungsort string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
AVVVerfuegbar bool
|
||||
// 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
|
||||
TrainingStandard bool
|
||||
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
|
||||
@@ -206,7 +242,7 @@ func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||
case "avv_erforderlich":
|
||||
return w.AVVVerfuegbar
|
||||
case "eu_verarbeitung":
|
||||
return w.Verarbeitungsort == "EU"
|
||||
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||
case "kein_training_auf_eingabe":
|
||||
return w.TrainingStandard
|
||||
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) {
|
||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||
@@ -166,8 +181,8 @@ func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||
|
||||
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||
kandidaten := []rules.WerkzeugKandidat{
|
||||
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}},
|
||||
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, 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, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
||||
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
|
||||
}
|
||||
|
||||
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||
// Betreiber-gestützten Anlage korrigieren).
|
||||
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: update account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
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) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
@@ -191,6 +224,9 @@ func TestSessionCRUD(t *testing.T) {
|
||||
if got.UserID != user.ID {
|
||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||
}
|
||||
if got.ImpersonatedByUserID != nil {
|
||||
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||
}
|
||||
|
||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
@@ -207,3 +243,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateImpersonatedSession(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
kundenAcc := testAccountID(t, s)
|
||||
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||
}
|
||||
betreiberAcc := testAccountID(t, s)
|
||||
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||
}
|
||||
if sess.UserID != kundenNutzer.ID {
|
||||
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||
}
|
||||
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetSession(ctx, sess.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||
// statt durch den regulären Login des Nutzers selbst.
|
||||
type Session struct {
|
||||
Token string
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Token string
|
||||
UserID string
|
||||
ImpersonatedByUserID *string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||
|
||||
func scanSession(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Session, error) {
|
||||
var sess Session
|
||||
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
return sess, err
|
||||
}
|
||||
|
||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||
var sess Session
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING token, user_id, expires_at, created_at
|
||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
RETURNING `+sessionColumns,
|
||||
token, userID, expiresAt,
|
||||
)
|
||||
sess, err := scanSession(row)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+sessionColumns,
|
||||
token, userID, impersonatedByUserID, expiresAt,
|
||||
)
|
||||
sess, err := scanSession(row)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||
var sess Session
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||
sess, err := scanSession(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -16,17 +16,30 @@ import (
|
||||
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
|
||||
// muss belegbar sein.
|
||||
type Werkzeug struct {
|
||||
ID string
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
ID string
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter 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
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
// 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
|
||||
Subprozessoren []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
@@ -36,37 +49,39 @@ type Werkzeug struct {
|
||||
}
|
||||
|
||||
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
||||
// Create/Update — bei 14 Feldern lesbarer als eine positionale
|
||||
// Create/Update — bei 15 Feldern lesbarer als eine positionale
|
||||
// Parameterliste.
|
||||
type WerkzeugInput struct {
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungslaender []string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
DPFZertifiziert bool
|
||||
AufbewahrungTage *int
|
||||
Zertifizierungen []string
|
||||
Subprozessoren []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
}
|
||||
|
||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
||||
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||
|
||||
func scanWerkzeug(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Werkzeug, error) {
|
||||
var w Werkzeug
|
||||
err := row.Scan(
|
||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
||||
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
|
||||
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
|
||||
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
return w, err
|
||||
}
|
||||
@@ -77,6 +92,9 @@ func scanWerkzeug(row interface {
|
||||
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||
// Fall wie früher bei finding.sources).
|
||||
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
if in.Verarbeitungslaender == nil {
|
||||
in.Verarbeitungslaender = []string{}
|
||||
}
|
||||
if in.Zertifizierungen == nil {
|
||||
in.Zertifizierungen = []string{}
|
||||
}
|
||||
@@ -86,6 +104,9 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
if in.Einschraenkungen == nil {
|
||||
in.Einschraenkungen = []string{}
|
||||
}
|
||||
if in.Subprozessoren == nil {
|
||||
in.Subprozessoren = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||
@@ -93,13 +114,13 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO werkzeug (
|
||||
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
||||
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING `+werkzeugColumns,
|
||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
@@ -118,14 +139,14 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
UPDATE werkzeug SET
|
||||
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
||||
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
||||
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
||||
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
|
||||
letzte_pruefung = $15, quelle = $16, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+werkzeugColumns,
|
||||
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
|
||||
@@ -9,22 +9,25 @@ import (
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: "Beispiel-Anbieter GmbH",
|
||||
Verarbeitungsort: "EU",
|
||||
AVVVerfuegbar: true,
|
||||
AVVURL: "https://beispiel.example/avv",
|
||||
TrainingOptOut: true,
|
||||
TrainingStandard: true,
|
||||
AufbewahrungTage: 30,
|
||||
Zertifizierungen: []string{"ISO 27001"},
|
||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
Einschraenkungen: nil,
|
||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||
Quelle: "https://beispiel.example/beleg",
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: "Beispiel-Anbieter GmbH",
|
||||
Verarbeitungslaender: []string{"Deutschland"},
|
||||
AVVVerfuegbar: true,
|
||||
AVVURL: "https://beispiel.example/avv",
|
||||
TrainingOptOut: true,
|
||||
TrainingStandard: true,
|
||||
AufbewahrungTage: intPtr(30),
|
||||
Zertifizierungen: []string{"ISO 27001"},
|
||||
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
|
||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
Einschraenkungen: nil,
|
||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||
Quelle: "https://beispiel.example/beleg",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +45,24 @@ func TestWerkzeugCRUD(t *testing.T) {
|
||||
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
||||
}
|
||||
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
|
||||
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
|
||||
}
|
||||
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
|
||||
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
|
||||
}
|
||||
|
||||
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
|
||||
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
|
||||
in.AufbewahrungTage = nil
|
||||
return in
|
||||
}())
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
|
||||
}
|
||||
if unbekannt.AufbewahrungTage != nil {
|
||||
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
|
||||
}
|
||||
|
||||
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||
if err != nil {
|
||||
@@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||
updateInput.AufbewahrungTage = 14
|
||||
updateInput.AufbewahrungTage = intPtr(14)
|
||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWerkzeug: %v", err)
|
||||
}
|
||||
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
|
||||
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage == nil || *updated.AufbewahrungTage != 14 {
|
||||
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||
}
|
||||
|
||||
|
||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
|
||||
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
|
||||
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
|
||||
// und kann entscheiden, genau wie ein verantwortlicher.
|
||||
func TestAdminSiehtPosteingang(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/faelle")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannEntscheiden(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if antrag.Status != "entschieden" {
|
||||
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
|
||||
resp := getWithCookie(t, s, adminCookie, path)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,115 @@ var datenfragen = []struct {
|
||||
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
||||
}
|
||||
|
||||
var c2FolgeLabels = map[string]string{
|
||||
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
|
||||
"kreditwuerdigkeit": "Kreditwürdigkeit",
|
||||
"leistungsbewilligung": "Leistungsbewilligung",
|
||||
"bildung": "Zugang zu Bildung",
|
||||
"gesundheit": "medizinische Beurteilung",
|
||||
}
|
||||
|
||||
var c3ArtLabels = map[string]string{
|
||||
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
|
||||
"social_scoring": "Social Scoring",
|
||||
"biometrische_kategorisierung": "Biometrische Kategorisierung",
|
||||
}
|
||||
|
||||
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
|
||||
// siehe antwortenAnzeige.
|
||||
type antwortZeile struct {
|
||||
Frage string
|
||||
Antwort string
|
||||
}
|
||||
|
||||
func antwortLabel(v string) string {
|
||||
switch v {
|
||||
case "ja":
|
||||
return "Ja"
|
||||
case "nein":
|
||||
return "Nein"
|
||||
case "unsicher":
|
||||
return "Unsicher"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func jaNein(v bool) string {
|
||||
if v {
|
||||
return "Ja"
|
||||
}
|
||||
return "Nein"
|
||||
}
|
||||
|
||||
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
|
||||
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
|
||||
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
|
||||
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
|
||||
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
|
||||
// Antworten, auf denen sie beruht — im Audit ist das nicht
|
||||
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
|
||||
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
|
||||
var out []antwortZeile
|
||||
for _, f := range datenfragen {
|
||||
v, _ := antworten[f.Key].(string)
|
||||
if v == "" {
|
||||
v = "nein"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
|
||||
}
|
||||
|
||||
c1, _ := antworten["c1"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
|
||||
|
||||
c2, _ := antworten["c2"].(bool)
|
||||
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
|
||||
if c2 {
|
||||
if folge, _ := antworten["c2_folge"].(string); folge != "" {
|
||||
if label, ok := c2FolgeLabels[folge]; ok {
|
||||
c2Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c2Zeile)
|
||||
|
||||
c3, _ := antworten["c3"].(bool)
|
||||
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
|
||||
if c3 {
|
||||
if art, _ := antworten["c3_art"].(string); art != "" {
|
||||
if label, ok := c3ArtLabels[art]; ok {
|
||||
c3Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c3Zeile)
|
||||
|
||||
c4, _ := antworten["c4"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
|
||||
|
||||
c5, _ := antworten["c5"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
|
||||
|
||||
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
|
||||
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
|
||||
}
|
||||
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
|
||||
label := "Privater Zugang"
|
||||
if zugang == "firma" {
|
||||
label = "Firmenkonto"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
|
||||
}
|
||||
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
|
||||
label := "Privatgerät"
|
||||
if geraet == "firma" {
|
||||
label = "Firmengerät"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type antragFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
@@ -180,9 +289,13 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
||||
}
|
||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||
for i, w := range werkzeuge {
|
||||
aufbewahrungTage := 0
|
||||
if w.AufbewahrungTage != nil {
|
||||
aufbewahrungTage = *w.AufbewahrungTage
|
||||
}
|
||||
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
|
||||
}}
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||
@@ -251,7 +364,6 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type anforderungView struct {
|
||||
ID string
|
||||
Beschreibung string
|
||||
Herleitung string
|
||||
}
|
||||
@@ -278,14 +390,13 @@ type bewertungView struct {
|
||||
type antragDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
ID string
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Status string
|
||||
CreatedAt string
|
||||
Antworten map[string]any
|
||||
Antworten []antwortZeile
|
||||
Bewertung *bewertungView
|
||||
}
|
||||
|
||||
@@ -306,9 +417,9 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
data := antragDetailData{
|
||||
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||
Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
|
||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
||||
}
|
||||
|
||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||
@@ -333,7 +444,7 @@ func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewert
|
||||
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, a := range b.Anforderungen {
|
||||
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||
v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||
}
|
||||
for _, id := range b.ZulaessigeWerkzeuge {
|
||||
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||
|
||||
@@ -75,8 +75,15 @@ func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
||||
if detailResp.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String())
|
||||
body := detailResp.Body.String()
|
||||
if !strings.Contains(body, "Angebotstexte generieren") {
|
||||
t.Errorf("expected the antrag title on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
return err
|
||||
}
|
||||
setSessionCookie(w, r, token, expiresAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||
// (Support-Login).
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
@@ -149,5 +157,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
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 (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type betreiberDashboardData struct {
|
||||
@@ -72,9 +75,103 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAccountNeuData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
|
||||
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
|
||||
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
|
||||
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
|
||||
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
|
||||
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
|
||||
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountName := r.FormValue("account_name")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
if err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
|
||||
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
|
||||
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
|
||||
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
|
||||
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
|
||||
// audit_log laufen).
|
||||
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
|
||||
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type betreiberUserView struct {
|
||||
Email string
|
||||
Role string
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type betreiberAccountDetailData struct {
|
||||
@@ -105,7 +202,7 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
|
||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, u := range users {
|
||||
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
||||
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
@@ -144,3 +241,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||
betreiber := currentUser(r)
|
||||
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !ziel.Active {
|
||||
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, expiresAt)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,34 @@ import (
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
|
||||
// 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
|
||||
}
|
||||
|
||||
func istGueltigerVerarbeitungsort(v string) bool {
|
||||
for _, o := range gueltigeVerarbeitungsorte {
|
||||
if o == v {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -40,11 +63,32 @@ func splitListe(s string) []string {
|
||||
}
|
||||
|
||||
type werkzeugListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
LetztePruefung string
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungslaender string
|
||||
DPFZertifiziert bool
|
||||
AVVVerfuegbar bool
|
||||
TrainingStandard bool
|
||||
Aufbewahrung string
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
LetztePruefung string
|
||||
PruefStatusLabel string
|
||||
PruefStatusCSS string
|
||||
}
|
||||
|
||||
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
|
||||
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
|
||||
// sofortigen Löschung) verwechselt wird.
|
||||
func formatAufbewahrung(tage *int) string {
|
||||
if tage == nil {
|
||||
return "unbekannt"
|
||||
}
|
||||
if *tage == 0 {
|
||||
return "0 Tage (sofort)"
|
||||
}
|
||||
return strconv.Itoa(*tage) + " Tage"
|
||||
}
|
||||
|
||||
type werkzeugListeData struct {
|
||||
@@ -53,6 +97,34 @@ type werkzeugListeData struct {
|
||||
Werkzeuge []werkzeugListItem
|
||||
}
|
||||
|
||||
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
|
||||
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
|
||||
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
|
||||
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
|
||||
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
|
||||
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
|
||||
// nicht erst wenn die Frist schon gerissen ist.
|
||||
const (
|
||||
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
|
||||
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
|
||||
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
|
||||
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
|
||||
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
|
||||
func pruefStatus(letztePruefung time.Time) (label, css string) {
|
||||
alter := time.Since(letztePruefung)
|
||||
switch {
|
||||
case alter > werkzeugPruefungVeraltetNach:
|
||||
return "abgelaufen", "status-hoch"
|
||||
case alter > werkzeugPruefungBaldFaelligNach:
|
||||
return "bald fällig", "status-mittel"
|
||||
default:
|
||||
return "aktuell", "status-niedrig"
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||
@@ -62,9 +134,15 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
for _, wz := range werkzeuge {
|
||||
label, css := pruefStatus(wz.LetztePruefung)
|
||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
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"),
|
||||
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||
@@ -77,29 +155,67 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
|
||||
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
|
||||
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
|
||||
type werkzeugFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
ActionBase string
|
||||
ID string // leer bei "neu"
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsorte []string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
Einschraenkungen string
|
||||
LetztePruefung string
|
||||
Quelle string
|
||||
Error string
|
||||
Title string
|
||||
Nav navData
|
||||
ActionBase string
|
||||
ID string // leer bei "neu"
|
||||
Name string
|
||||
Anbieter string
|
||||
LaenderOptionen []string
|
||||
LaenderAusgewaehlt map[string]bool
|
||||
DPFZertifiziert bool
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
|
||||
Zertifizierungen string
|
||||
Subprozessoren string
|
||||
ZweckeOptionen []string
|
||||
ZweckeAusgewaehlt map[string]bool
|
||||
Einschraenkungen string
|
||||
LetztePruefung string
|
||||
Quelle 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) {
|
||||
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 {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -113,9 +229,14 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
||||
if err != nil {
|
||||
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||
}
|
||||
verarbeitungsort := r.FormValue("verarbeitungsort")
|
||||
if !istGueltigerVerarbeitungsort(verarbeitungsort) {
|
||||
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort"
|
||||
var verarbeitungslaender []string
|
||||
for _, l := range r.Form["verarbeitungslaender"] {
|
||||
if istGueltigesLand(l) {
|
||||
verarbeitungslaender = append(verarbeitungslaender, l)
|
||||
}
|
||||
}
|
||||
if len(verarbeitungslaender) == 0 {
|
||||
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
anbieter := r.FormValue("anbieter")
|
||||
@@ -123,23 +244,39 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
||||
if name == "" || anbieter == "" || quelle == "" {
|
||||
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
||||
}
|
||||
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
|
||||
var aufbewahrungTage *int
|
||||
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
|
||||
wert, err := strconv.Atoi(rohwert)
|
||||
if err != nil || wert < 0 {
|
||||
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
|
||||
}
|
||||
aufbewahrungTage = &wert
|
||||
}
|
||||
|
||||
var geeigneteZwecke []string
|
||||
for _, z := range r.Form["geeignete_zwecke"] {
|
||||
if istGueltigerZweck(z) {
|
||||
geeigneteZwecke = append(geeigneteZwecke, z)
|
||||
}
|
||||
}
|
||||
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: anbieter,
|
||||
Verarbeitungsort: verarbeitungsort,
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||
AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
AufbewahrungTage: aufbewahrungTage,
|
||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
|
||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||
LetztePruefung: letztePruefung,
|
||||
Quelle: quelle,
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: anbieter,
|
||||
Verarbeitungslaender: verarbeitungslaender,
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||
AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||
AufbewahrungTage: aufbewahrungTage,
|
||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||
GeeigneteZwecke: geeigneteZwecke,
|
||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||
LetztePruefung: letztePruefung,
|
||||
Quelle: quelle,
|
||||
}, ""
|
||||
}
|
||||
|
||||
@@ -148,11 +285,15 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
||||
// statt das Formular zu leeren.
|
||||
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||
return werkzeugFormData{
|
||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
|
||||
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"),
|
||||
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"),
|
||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||
}
|
||||
@@ -192,15 +333,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
Title: "Werkzeug bearbeiten", Nav: navFor(r), 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,
|
||||
}
|
||||
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func werkzeugForm() 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"},
|
||||
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||
|
||||
184
internal/web/csv_import_handlers.go
Normal file
184
internal/web/csv_import_handlers.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
|
||||
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
|
||||
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
|
||||
// beim einzelnen "Nutzer anlegen" generiert der Import ein
|
||||
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
|
||||
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
|
||||
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
|
||||
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
|
||||
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type csvMitarbeiterZeile struct {
|
||||
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
|
||||
Email string
|
||||
Role string
|
||||
}
|
||||
|
||||
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
|
||||
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
|
||||
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
|
||||
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
|
||||
// einzelne falsche Rolle nicht den ganzen Import verhindert).
|
||||
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
cr.FieldsPerRecord = -1
|
||||
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
|
||||
}
|
||||
emailCol, roleCol := -1, -1
|
||||
for i, h := range header {
|
||||
switch strings.ToLower(strings.TrimSpace(h)) {
|
||||
case "email", "e-mail":
|
||||
emailCol = i
|
||||
case "role", "rolle":
|
||||
roleCol = i
|
||||
}
|
||||
}
|
||||
if emailCol == -1 {
|
||||
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
|
||||
}
|
||||
|
||||
var out []csvMitarbeiterZeile
|
||||
zeile := 1
|
||||
for {
|
||||
record, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
|
||||
}
|
||||
zeile++
|
||||
if emailCol >= len(record) {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(record[emailCol])
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
role := "mitarbeiter"
|
||||
if roleCol != -1 && roleCol < len(record) {
|
||||
if v := strings.TrimSpace(record[roleCol]); v != "" {
|
||||
role = v
|
||||
}
|
||||
}
|
||||
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
|
||||
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
|
||||
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
|
||||
// Länge, die sich noch manuell weitergeben lässt.
|
||||
func generateEinmalPasswort() (string, error) {
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token[:16], nil
|
||||
}
|
||||
|
||||
type csvImportFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type csvImportErgebnisZeile struct {
|
||||
Zeile int
|
||||
Email string
|
||||
Role string
|
||||
Passwort string // nur bei Erfolg gesetzt
|
||||
Fehler string // nur bei Fehler gesetzt
|
||||
}
|
||||
|
||||
type csvImportErgebnisData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Ergebnisse []csvImportErgebnisZeile
|
||||
}
|
||||
|
||||
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
|
||||
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
|
||||
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
|
||||
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
|
||||
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
|
||||
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("csv")
|
||||
if err != nil {
|
||||
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zeilen, err := parseCSVMitarbeiter(file)
|
||||
if err != nil {
|
||||
s.renderCSVImportForm(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if len(zeilen) == 0 {
|
||||
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
|
||||
return
|
||||
}
|
||||
|
||||
accountID := currentUser(r).AccountID
|
||||
var ergebnisse []csvImportErgebnisZeile
|
||||
for _, z := range zeilen {
|
||||
if !istMandantenRolle(z.Role) {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
|
||||
continue
|
||||
}
|
||||
passwort, err := generateEinmalPasswort()
|
||||
if err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
|
||||
continue
|
||||
}
|
||||
hash, err := auth.HashPassword(passwort)
|
||||
if err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
|
||||
continue
|
||||
}
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
|
||||
}
|
||||
|
||||
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
149
internal/web/csv_import_handlers_test.go
Normal file
149
internal/web/csv_import_handlers_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
|
||||
// — reale Browser-Uploads laufen genauso über multipart/form-data.
|
||||
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(csvContent)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("mw.Close: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
|
||||
t.Fatalf("expected both imported emails in the result, got: %s", body)
|
||||
}
|
||||
|
||||
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail (lisa): %v", err)
|
||||
}
|
||||
if lisa.Role != "verantwortlicher" {
|
||||
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
|
||||
}
|
||||
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail (tom): %v", err)
|
||||
}
|
||||
if tom.Role != "mitarbeiter" {
|
||||
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
|
||||
body := resp.Body.String()
|
||||
start := strings.Index(body, "<code>")
|
||||
end := strings.Index(body, "</code>")
|
||||
if start == -1 || end == -1 {
|
||||
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
|
||||
}
|
||||
passwort := body[start+len("<code>") : end]
|
||||
|
||||
loginResp := postForm(t, s, nil, "/login", map[string][]string{
|
||||
"email": {"neu@example.com"}, "password": {passwort},
|
||||
})
|
||||
if loginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
|
||||
t.Error("expected no user to be created for an invalid role (betreiber)")
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
|
||||
t.Error("expected the valid row to still be imported despite the earlier invalid row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("mw.Close: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(adminCookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
|
||||
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
64
internal/web/csv_import_parse_test.go
Normal file
64
internal/web/csv_import_parse_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
|
||||
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 2 {
|
||||
t.Fatalf("zeilen = %+v, want 2", zeilen)
|
||||
}
|
||||
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
|
||||
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
|
||||
}
|
||||
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
|
||||
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
|
||||
csv := "email\nlisa@example.com\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
|
||||
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
|
||||
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
|
||||
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
|
||||
csv := "name,role\nLisa,mitarbeiter\n"
|
||||
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the header has no email column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
|
||||
csv := "email\nlisa@example.com\ntom@example.com\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
|
||||
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -71,6 +72,7 @@ type fallDetailData struct {
|
||||
Haeufigkeit string
|
||||
Status string
|
||||
CreatedAt string
|
||||
Antworten []antwortZeile
|
||||
Bewertung *bewertungView
|
||||
Entscheidung *entscheidungView
|
||||
CanEntscheiden bool
|
||||
@@ -78,6 +80,13 @@ type fallDetailData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
|
||||
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
|
||||
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
|
||||
func hatEntscheidungsrecht(role string) bool {
|
||||
return role == "verantwortlicher" || role == "admin"
|
||||
}
|
||||
|
||||
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
|
||||
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
||||
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
||||
@@ -102,10 +111,15 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var antworten map[string]any
|
||||
if len(antrag.Antworten) > 0 {
|
||||
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||
}
|
||||
|
||||
data := fallDetailData{
|
||||
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"),
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
||||
}
|
||||
|
||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||
@@ -115,7 +129,7 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
||||
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
|
||||
}
|
||||
}
|
||||
data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil
|
||||
data.CanEntscheiden = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil
|
||||
|
||||
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||
if err == nil {
|
||||
@@ -147,13 +161,14 @@ func gueltigkeitFuer(einstufung string) time.Time {
|
||||
}
|
||||
|
||||
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
||||
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein
|
||||
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die
|
||||
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein
|
||||
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden.
|
||||
// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
|
||||
// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
|
||||
// requireFachebene (404, weil die Seite für sie gar nicht existieren
|
||||
// soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall
|
||||
// existiert, nur nicht entscheiden.
|
||||
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user.Role != "verantwortlicher" {
|
||||
if !hatEntscheidungsrecht(user.Role) {
|
||||
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,6 +87,28 @@ func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
|
||||
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
|
||||
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
|
||||
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
|
||||
func TestFallDetailZeigtFragebogenAntworten(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
@@ -11,15 +11,49 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type indexData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Title string
|
||||
Nav navData
|
||||
OffeneAntraege int
|
||||
ZeigePosteingang bool
|
||||
OffenerPosteingang int
|
||||
}
|
||||
|
||||
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
|
||||
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
|
||||
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
|
||||
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
|
||||
// Überblick statt reiner Navigations-Duplikate (die Links stehen
|
||||
// eh schon in der Seitenleiste): eigene offene Anträge, und für die
|
||||
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
|
||||
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
|
||||
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
|
||||
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
|
||||
// bei jedem Login geladen wird, zu teuer.
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data := indexData{Title: "Start", Nav: navFor(r)}
|
||||
user := currentUser(r)
|
||||
nav := navFor(r)
|
||||
data := indexData{Title: "Start", Nav: nav}
|
||||
|
||||
if !nav.IsBetreiber {
|
||||
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
|
||||
if err == nil {
|
||||
for _, a := range eigene {
|
||||
if a.Status == "eingereicht" {
|
||||
data.OffeneAntraege++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if nav.IsFachebene {
|
||||
data.ZeigePosteingang = true
|
||||
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
|
||||
if err == nil {
|
||||
for _, a := range alle {
|
||||
if a.Status == "eingereicht" {
|
||||
data.OffenerPosteingang++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
63
internal/web/handlers_test.go
Normal file
63
internal/web/handlers_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/antraege">Meine offenen Anträge <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/faelle">Posteingang <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtKeinenPosteingangFuerMitarbeiter(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Posteingang") {
|
||||
t.Errorf("expected no Posteingang tile for mitarbeiter, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/betreiber">Zur Plattform`) {
|
||||
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
120
internal/web/impersonation_test.go
Normal file
120
internal/web/impersonation_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
cookies := resp.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("expected a new session cookie")
|
||||
}
|
||||
neueCookie := cookies[0]
|
||||
|
||||
// Die neue Sitzung ist tatsächlich der Kunden-Login.
|
||||
protected := getWithCookie(t, s, neueCookie, "/antraege")
|
||||
if protected.Code != http.StatusOK {
|
||||
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||
neueCookie := loginResp.Result().Cookies()[0]
|
||||
|
||||
resp := getWithCookie(t, s, neueCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
|
||||
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||
|
||||
var gefunden bool
|
||||
for _, e := range fs.auditLog {
|
||||
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
|
||||
gefunden = true
|
||||
}
|
||||
}
|
||||
if !gefunden {
|
||||
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||
})
|
||||
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotLoginAls(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
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",
|
||||
}
|
||||
@@ -14,16 +14,26 @@ import (
|
||||
)
|
||||
|
||||
type mandantWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Gesperrt bool
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungslaender string
|
||||
AVVVerfuegbar bool
|
||||
TrainingStandard bool
|
||||
Aufbewahrung string
|
||||
GeeigneteZwecke string
|
||||
LetztePruefung string
|
||||
PruefStatusLabel string
|
||||
PruefStatusCSS string
|
||||
Gesperrt bool
|
||||
}
|
||||
|
||||
type eigenesWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungslaender string
|
||||
LetztePruefung string
|
||||
}
|
||||
|
||||
type mandantWerkzeugListeData struct {
|
||||
@@ -61,15 +71,25 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
for _, wz := range zentral {
|
||||
label, css := pruefStatus(wz.LetztePruefung)
|
||||
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID],
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, 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 {
|
||||
if wz.AccountID == nil {
|
||||
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 {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
@@ -77,7 +97,11 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||
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 {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -121,15 +145,7 @@ func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Re
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
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,
|
||||
}
|
||||
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -12,44 +12,67 @@ const sessionCookieName = "deklarix_session"
|
||||
|
||||
type contextKey int
|
||||
|
||||
const userContextKey contextKey = iota
|
||||
const (
|
||||
userContextKey contextKey = iota
|
||||
impersonatorContextKey
|
||||
)
|
||||
|
||||
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
|
||||
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
||||
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
|
||||
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
|
||||
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
|
||||
// sonst leer.
|
||||
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return store.User{}, false
|
||||
return store.User{}, "", false
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
return store.User{}, false
|
||||
return store.User{}, "", false
|
||||
}
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
return store.User{}, false
|
||||
return store.User{}, "", false
|
||||
}
|
||||
|
||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||
if err != nil || !user.Active {
|
||||
return store.User{}, false
|
||||
return store.User{}, "", false
|
||||
}
|
||||
return user, true
|
||||
|
||||
impersonator := ""
|
||||
if sess.ImpersonatedByUserID != nil {
|
||||
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
|
||||
impersonator = betreiber.Email
|
||||
}
|
||||
}
|
||||
return user, impersonator, true
|
||||
}
|
||||
|
||||
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
|
||||
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
|
||||
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
if impersonator != "" {
|
||||
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||
}
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +82,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +99,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
@@ -85,30 +108,37 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
|
||||
// Seite tatsächlich entschieden werden darf, prüft der einzelne
|
||||
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
|
||||
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
|
||||
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
|
||||
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
|
||||
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
|
||||
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
|
||||
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
|
||||
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
|
||||
// — ohne das könnte eine frisch registrierte Firma mit nur einem
|
||||
// admin-Login keinen einzigen eingereichten Antrag sehen oder
|
||||
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
|
||||
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
|
||||
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
||||
// nicht erfährt, dass es die Seite überhaupt gibt.
|
||||
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
|
||||
if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +147,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
@@ -126,7 +156,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,9 +165,10 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||
// Plattform-Link gezeigt werden soll.
|
||||
type navData struct {
|
||||
IsBetreiber bool
|
||||
IsFachebene bool
|
||||
IsAdmin bool
|
||||
IsBetreiber bool
|
||||
IsFachebene bool
|
||||
IsAdmin bool
|
||||
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
|
||||
}
|
||||
|
||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||
@@ -147,8 +178,10 @@ func navFor(r *http.Request) navData {
|
||||
role := currentUser(r).Role
|
||||
return navData{
|
||||
IsBetreiber: role == "betreiber",
|
||||
IsFachebene: role == "verantwortlicher" || role == "pruefer",
|
||||
IsAdmin: role == "admin",
|
||||
// admin sieht die Fachebene mit, siehe requireFachebene.
|
||||
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||
IsAdmin: role == "admin",
|
||||
Impersonator: currentImpersonator(r),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,3 +196,10 @@ func currentUser(r *http.Request) store.User {
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// currentImpersonator liefert die E-Mail des Betreibers, falls die
|
||||
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
|
||||
func currentImpersonator(r *http.Request) string {
|
||||
v, _ := r.Context().Value(impersonatorContextKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ var staticFS embed.FS
|
||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||
type Store interface {
|
||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||
@@ -39,6 +40,7 @@ type Store interface {
|
||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||
SetUserActive(ctx context.Context, id string, active bool) error
|
||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||
@@ -134,6 +136,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
|
||||
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
|
||||
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
||||
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
||||
@@ -149,7 +153,11 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
||||
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
|
||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
|
||||
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||
|
||||
@@ -70,6 +70,18 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc, ok := f.accounts[id]
|
||||
if !ok {
|
||||
return store.Account{}, store.ErrNotFound
|
||||
}
|
||||
acc.Name = name
|
||||
f.accounts[id] = acc
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -180,6 +192,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||
f.sessions[token] = sess
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -388,9 +408,9 @@ func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput)
|
||||
defer f.mu.Unlock()
|
||||
w := store.Werkzeug{
|
||||
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
||||
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
||||
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage,
|
||||
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
||||
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
||||
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
|
||||
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
||||
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
f.werkzeuge[w.ID] = w
|
||||
@@ -404,10 +424,10 @@ func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.Werk
|
||||
if !ok {
|
||||
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.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage
|
||||
w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen
|
||||
w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
|
||||
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()
|
||||
f.werkzeuge[id] = w
|
||||
return w, nil
|
||||
|
||||
@@ -59,7 +59,15 @@ h1, h2, h3 {
|
||||
color: var(--color-heading);
|
||||
font-weight: 600;
|
||||
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 {
|
||||
@@ -275,6 +283,38 @@ nav button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
|
||||
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
|
||||
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
|
||||
.impersonation-banner {
|
||||
background: var(--color-mittel-bg);
|
||||
color: var(--color-mittel);
|
||||
padding: 10px 16px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--color-mittel);
|
||||
}
|
||||
|
||||
.impersonation-banner form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.impersonation-banner button {
|
||||
margin-top: 0;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.impersonation-banner {
|
||||
margin-left: var(--sidebar-width);
|
||||
}
|
||||
}
|
||||
|
||||
.fehler {
|
||||
background: var(--color-hoch-bg);
|
||||
color: var(--color-hoch);
|
||||
@@ -360,6 +400,16 @@ nav button {
|
||||
color: var(--color-mittel);
|
||||
}
|
||||
|
||||
.status-niedrig {
|
||||
background: var(--color-niedrig-bg);
|
||||
color: var(--color-niedrig);
|
||||
}
|
||||
|
||||
.status-hoch {
|
||||
background: var(--color-hoch-bg);
|
||||
color: var(--color-hoch);
|
||||
}
|
||||
|
||||
.beitraege-liste {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
@@ -500,8 +550,8 @@ button.entfernen {
|
||||
}
|
||||
|
||||
.fragebogen legend {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@@ -513,6 +563,130 @@ button.entfernen {
|
||||
margin: 4px 12px 4px 0;
|
||||
}
|
||||
|
||||
/* ── Formular-Karten (Enterprise-Look, siehe design/enterprise.css
|
||||
.ant-card/.ant-form-item) ─────────────────────────────────────────
|
||||
Ersetzt "ein Formular = eine flache, ununterbrochene Spalte" durch
|
||||
klar abgegrenzte Abschnitte mit Titel und ein Grid für kurze Felder
|
||||
nebeneinander statt jedes Feld einzeln untereinander. */
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-card form {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-section + .form-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* <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"] {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
@@ -563,11 +737,17 @@ tbody tr:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* Ohne dieses Signal sieht eine breite, am Fensterrand abgeschnittene
|
||||
Tabelle wie ein Rendering-Fehler aus statt wie "hier geht's mit
|
||||
Scrollen weiter" — der Fade ist bewusst immer sichtbar (kein JS, das
|
||||
den Scroll-Zustand prüfen könnte), auch am Ende der Tabelle. */
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||
}
|
||||
|
||||
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||
|
||||
{{if .Antworten}}
|
||||
<h2>Antworten im Fragebogen</h2>
|
||||
<ul class="findings">
|
||||
{{range .Antworten}}
|
||||
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if .Bewertung}}
|
||||
{{template "bewertung-block" .Bewertung}}
|
||||
{{else}}
|
||||
|
||||
@@ -8,11 +8,25 @@
|
||||
<h1>{{.Name}}</h1>
|
||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||
|
||||
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
|
||||
<label for="name">Firmenname</label>
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||
<button type="submit">Umbenennen</button>
|
||||
</form>
|
||||
|
||||
<h2>Nutzer</h2>
|
||||
<ul class="beteiligte">
|
||||
{{range .Users}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
||||
<div class="beteiligter-kopf">
|
||||
{{.Email}} <span class="rolle">({{.Role}})</span>
|
||||
{{if not .Active}}<span class="status">deaktiviert</span>{{end}}
|
||||
</div>
|
||||
{{if .Active}}
|
||||
<form method="post" action="/betreiber/accounts/{{$.AccountID}}/nutzer/{{.ID}}/anmelden-als" onsubmit="return confirm('Als {{.Email}} anmelden? Ihre eigene Sitzung wird dabei ersetzt.')">
|
||||
<button type="submit">Als Nutzer anmelden</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
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}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,59 +7,113 @@
|
||||
<p><a href="{{.ActionBase}}">← Werkzeugkatalog</a></p>
|
||||
<h1>{{.Title}}</h1>
|
||||
<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
|
||||
Pflicht.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||
|
||||
<label for="anbieter">Anbieter</label>
|
||||
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
|
||||
|
||||
<fieldset>
|
||||
<legend>Verarbeitungsort</legend>
|
||||
{{$gewaehlt := .Verarbeitungsort}}
|
||||
{{range .Verarbeitungsorte}}
|
||||
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
|
||||
{{end}}
|
||||
<fieldset class="form-section">
|
||||
<legend>Grunddaten</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="anbieter">Anbieter</label>
|
||||
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="verarbeitungslaender">Verarbeitungsländer (Mehrfachauswahl: Strg/Cmd-Klick)</label>
|
||||
<select id="verarbeitungslaender" name="verarbeitungslaender" multiple size="8" required>
|
||||
{{range .LaenderOptionen}}
|
||||
<option value="{{.}}" {{if index $.LaenderAusgewaehlt .}}selected{{end}}>{{.}}</option>
|
||||
{{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>
|
||||
|
||||
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
|
||||
<label for="avv_url">AVV-URL</label>
|
||||
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
|
||||
<fieldset class="form-section">
|
||||
<legend>Rechtliches & Datenschutz</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-full">
|
||||
<label>Zusicherungen</label>
|
||||
<div class="chip-group">
|
||||
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
|
||||
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
|
||||
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
|
||||
<label><input type="checkbox" name="dpf_zertifiziert" {{if .DPFZertifiziert}}checked{{end}}> EU-US Data Privacy Framework (DPF) zertifiziert</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="avv_url">AVV-URL</label>
|
||||
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label>
|
||||
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
||||
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
|
||||
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
|
||||
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
|
||||
<fieldset class="form-section">
|
||||
<legend>Einsatzbereich</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-full">
|
||||
<label>Geeignete Zwecke</label>
|
||||
<div class="chip-group">
|
||||
{{range .ZweckeOptionen}}
|
||||
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label for="aufbewahrung_tage">Aufbewahrung (Tage)</label>
|
||||
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
|
||||
<fieldset class="form-section">
|
||||
<legend>Beleg</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="letzte_pruefung">Letzte Prüfung</label>
|
||||
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="quelle">Quelle (Beleg)</label>
|
||||
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
||||
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
||||
|
||||
<label for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
|
||||
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
|
||||
|
||||
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||
|
||||
<label for="letzte_pruefung">Letzte Prüfung</label>
|
||||
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
||||
|
||||
<label for="quelle">Quelle (Beleg)</label>
|
||||
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
||||
|
||||
<button type="submit">Speichern</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .ID}}
|
||||
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
||||
<button type="submit" class="entfernen">Werkzeug löschen</button>
|
||||
</form>
|
||||
<div class="form-gefahrenzone">
|
||||
<span class="hinweis">Ein gelöschtes Werkzeug kann nicht wiederhergestellt werden.</span>
|
||||
<form method="post" action="{{.ActionBase}}/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
||||
<button type="submit" class="entfernen">Werkzeug löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -9,15 +9,33 @@
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Werkzeuge}}
|
||||
<li>
|
||||
<a href="/betreiber/werkzeuge/{{.ID}}">
|
||||
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Anbieter</th><th>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}}
|
||||
<tr>
|
||||
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungslaender}}</td>
|
||||
<td>{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{.Aufbewahrung}}</td>
|
||||
<td>{{.Zertifizierungen}}</td>
|
||||
<td>{{.GeeigneteZwecke}}</td>
|
||||
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||
|
||||
{{if .Antworten}}
|
||||
<h2>Antworten im Fragebogen</h2>
|
||||
<ul class="findings">
|
||||
{{range .Antworten}}
|
||||
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if .Bewertung}}
|
||||
{{template "bewertung-block" .Bewertung}}
|
||||
{{else}}
|
||||
|
||||
@@ -5,10 +5,19 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
{{if .Nav.IsBetreiber}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber">Zur Plattform</a></li>
|
||||
</ul>
|
||||
{{else}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||
<li><a href="/antraege">Meine Anträge</a></li>
|
||||
<li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</span></a></li>
|
||||
{{if .ZeigePosteingang}}
|
||||
<li><a href="/faelle">Posteingang <span class="status">{{.OffenerPosteingang}}</span></a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -37,6 +37,14 @@
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
{{if .Impersonator}}
|
||||
<div class="impersonation-banner">
|
||||
Support-Zugriff durch <strong>{{.Impersonator}}</strong> — dies ist nicht Ihre eigene Sitzung.
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Sitzung beenden</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||
|
||||
@@ -15,24 +15,43 @@
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Werkzeuge}}
|
||||
<li>
|
||||
{{.Name}} · {{.Anbieter}}
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
<button type="submit">Entsperren</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
|
||||
<input type="text" name="grund" placeholder="Grund (optional)">
|
||||
<button type="submit">Sperren</button>
|
||||
</form>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Anbieter</th><th>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}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungslaender}}</td>
|
||||
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{.Aufbewahrung}}</td>
|
||||
<td>{{.GeeigneteZwecke}}</td>
|
||||
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||
<td>
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
<button type="submit">Entsperren</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
|
||||
<input type="text" name="grund" placeholder="Grund (optional)">
|
||||
<button type="submit">Sperren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h2>Eigene Werkzeuge</h2>
|
||||
@@ -43,13 +62,23 @@
|
||||
{{if not .EigeneWerkzeuge}}
|
||||
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .EigeneWerkzeuge}}
|
||||
<li>
|
||||
<a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}} · {{.Anbieter}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .EigeneWerkzeuge}}
|
||||
<tr>
|
||||
<td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungslaender}}</td>
|
||||
<td>{{.LetztePruefung}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
|
||||
27
internal/web/templates/nutzer_import.html
Normal file
27
internal/web/templates/nutzer_import.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "nutzer-import"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/verwaltung/nutzer">← Nutzerverwaltung</a></p>
|
||||
<h1>Mitarbeiter per CSV importieren</h1>
|
||||
<p class="hinweis">
|
||||
CSV mit Kopfzeile, Pflichtspalte "email", optionale Spalte "role"
|
||||
(mitarbeiter/verantwortlicher/pruefer/admin — Standard: mitarbeiter).
|
||||
Es gibt keine Einladungsmail: nach dem Import wird pro Zeile ein
|
||||
Einmalpasswort angezeigt, das nur DIESES EINE MAL sichtbar ist — bitte
|
||||
auf dieser Seite kopieren und sicher weitergeben, bevor sie
|
||||
geschlossen wird.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
|
||||
<label for="csv">CSV-Datei</label>
|
||||
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
|
||||
|
||||
<button type="submit">Importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "nutzer-import-ergebnis"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Import-Ergebnis</h1>
|
||||
<p class="fehler">
|
||||
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
|
||||
angezeigt — jetzt kopieren und sicher weitergeben.
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Zeile</th><th>E-Mail</th><th>Rolle</th><th>Ergebnis</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Ergebnisse}}
|
||||
<tr>
|
||||
<td>{{.Zeile}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{if .Fehler}}—{{else}}{{.Role}}{{end}}</td>
|
||||
<td>{{if .Fehler}}<span class="fehler">{{.Fehler}}</span>{{else}}<code>{{.Passwort}}</code>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p><a href="/verwaltung/nutzer">Zur Nutzerverwaltung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -25,7 +25,7 @@
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a> · <a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,8 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
@@ -30,8 +32,9 @@ func werkzeugDiff(snapshot, current store.Werkzeug) []string {
|
||||
if snapshot.TrainingStandard != current.TrainingStandard {
|
||||
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
|
||||
}
|
||||
if snapshot.Verarbeitungsort != current.Verarbeitungsort {
|
||||
diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort))
|
||||
if !slices.Equal(snapshot.Verarbeitungslaender, current.Verarbeitungslaender) {
|
||||
diffs = append(diffs, fmt.Sprintf("Verarbeitungsländer: %s → %s",
|
||||
strings.Join(snapshot.Verarbeitungslaender, ", "), strings.Join(current.Verarbeitungslaender, ", ")))
|
||||
}
|
||||
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
|
||||
betrieblichen Datenschutzbeauftragten) klären.
|
||||
|
||||
## 1. Rangfolge der Datenklassen
|
||||
## 1. Rangfolge der Datenklassen — BESTÄTIGT (2026-08-29)
|
||||
|
||||
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
|
||||
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
|
||||
diese Reihenfolge an (niedrigster zu höchstem Rang):
|
||||
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt diese
|
||||
Reihenfolge an (niedrigster zu höchstem Rang) — **vom Produktverantwortlichen
|
||||
bestätigt, keine Änderung nötig:**
|
||||
|
||||
1. `oeffentlich`
|
||||
2. `intern`
|
||||
@@ -18,22 +19,22 @@ diese Reihenfolge an (niedrigster zu höchstem Rang):
|
||||
5. `berufsgeheimnis`
|
||||
6. `besondere_kategorie`
|
||||
|
||||
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
|
||||
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
|
||||
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
|
||||
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
|
||||
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
|
||||
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
|
||||
bestätigt.**
|
||||
Begründung: DSGVO Art. 9 (besondere Kategorien) gilt allgemein als
|
||||
striktester Datenschutz-Tatbestand, § 203 StGB (Berufsgeheimnis) hat
|
||||
eigene strafrechtliche Relevanz — beide vor "normalen" personenbezogenen
|
||||
Daten eingeordnet. `auftragsdaten` unter `personenbezogen` einsortiert,
|
||||
weil vertragliche Geheimhaltung in der Regel schwächer sanktioniert ist
|
||||
als DSGVO-Bußgelder.
|
||||
|
||||
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
|
||||
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten` — BESTÄTIGT (2026-08-29)
|
||||
|
||||
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
|
||||
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
||||
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
|
||||
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
|
||||
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
||||
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
|
||||
`rules/anforderungen.yaml` schließt `auftragsdaten` ein (Kundendaten
|
||||
sollten aus denselben Gründen wie Geschäftsgeheimnisse nicht zum
|
||||
Training verwendet werden) — **vom Produktverantwortlichen bestätigt,
|
||||
keine Änderung nötig.**
|
||||
|
||||
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
||||
|
||||
@@ -43,9 +44,9 @@ jetzt eine Folgefrage `c3_art` mit den Werten
|
||||
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
||||
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
||||
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
|
||||
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
|
||||
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
|
||||
final, nicht mehr Platzhalter. Ob diese drei Varianten fachlich
|
||||
vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
|
||||
**bestätigt (keine Ergänzung nötig).**
|
||||
|
||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||
|
||||
@@ -60,15 +61,15 @@ wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
||||
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
||||
muss die Filterfunktion entsprechend erweitert werden.
|
||||
|
||||
## 5. Fallback, wenn keine Datenklasse zutrifft
|
||||
## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
|
||||
|
||||
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
||||
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
||||
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
||||
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
||||
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
||||
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
|
||||
bestätigt**, nur eine sichere Standardannahme.
|
||||
Zweifel mehr Schutzanforderungen, nicht weniger. **Vom
|
||||
Produktverantwortlichen bestätigt, keine Änderung nötig.**
|
||||
|
||||
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user