Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8295dacd | ||
|
|
690660b655 | ||
|
|
f729e5ae48 | ||
|
|
9e2c7f92ff | ||
|
|
4c60603055 | ||
|
|
bc99858c94 | ||
|
|
3aecbb446c | ||
|
|
08c7721165 | ||
|
|
59e1bcc8f7 | ||
|
|
2e459f1dd1 | ||
|
|
d77b19f61f | ||
|
|
dbeaa45644 |
552
CLAUDE.md
552
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.
|
||||
|
||||
---
|
||||
@@ -251,12 +251,15 @@ 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
|
||||
werden im zentralen (`GET /betreiber/werkzeuge`) und im mandanten-
|
||||
seitigen Katalog (`GET /verwaltung/werkzeuge`) als "nicht aktuell
|
||||
geprüft" markiert (`werkzeugPruefungVeraltetNach`, 180 Tage).
|
||||
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
|
||||
@@ -266,13 +269,23 @@ aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
|
||||
"0 Tage".
|
||||
|
||||
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
|
||||
Vokabular statt Freitext: das Formular zeigt Checkboxen für genau die
|
||||
sechs Zweck-Kategorien aus dem Fragebogen (Textgenerierung,
|
||||
Übersetzung, Code, Bild, Transkription, Recherche — `gueltigeZwecke` in
|
||||
`betreiber_werkzeug_handlers.go`), serverseitig validiert
|
||||
(`istGueltigerZweck`). Grund: ein künftiger automatischer Zweckabgleich
|
||||
(Bewertungslogik Schritt 3, weiterhin nicht gebaut) würde an
|
||||
inkonsistentem Freitext ("Text" statt "Textgenerierung") scheitern.
|
||||
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,
|
||||
@@ -282,24 +295,107 @@ scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
|
||||
|
||||
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
|
||||
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
|
||||
als Tabelle (Name, Anbieter, Verarbeitungsort, AVV, Training aus,
|
||||
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 10 recherchierten Einträgen befüllt
|
||||
(ChatGPT Enterprise, Microsoft 365 Copilot, Claude for Work, GitHub
|
||||
Copilot Business, DeepL Pro/API, Google Gemini für Google Workspace,
|
||||
Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly Enterprise,
|
||||
Otter.ai Business) — deckt damit erstmals alle sechs Zweck-Kategorien
|
||||
ab (vorher fehlten Bild und Transkription vollständig, Recherche hatte
|
||||
kein dediziertes Werkzeug). Jede Zusicherung ist mit einer echten,
|
||||
**Filter & Paginierung** (seit 2026-08-31, `pagination.go` +
|
||||
`werkzeugFilter` in `betreiber_werkzeug_handlers.go`): beide
|
||||
Katalog-Tabellen haben eine Filterleiste (Freitext über Name/Anbieter/
|
||||
Verarbeitungsländer, Dropdown über die zwölf Zweck-Kategorien) und
|
||||
serverseitige Paginierung (20 Einträge/Seite, `page`-Query-Parameter).
|
||||
Alles über ein einziges GET-Formular, ohne JavaScript — Prev/Next-
|
||||
Buttons setzen `page` per `<button type="submit" name="page"
|
||||
value="...">`, damit q/zweck beim Blättern erhalten bleiben. Auf der
|
||||
Mandanten-Seite (die pro Zeile zusätzlich Sperren/Entsperren-Formulare
|
||||
hat) sind Filter und Paginierungs-Buttons bewusst in getrennte
|
||||
`<form>`-Elemente aufgeteilt statt in eines um die ganze Tabelle —
|
||||
verschachtelte `<form>`-Elemente sind ungültiges HTML, die
|
||||
Paginierungs-Buttons tragen `q`/`zweck` deshalb als Hidden-Felder
|
||||
nach. `pagination.go` ist bewusst generisch (Go-Generics,
|
||||
`paginate[T any]`) für Wiederverwendung in weiteren Listen (Register,
|
||||
Fälle, ...) — bisher nur auf den Werkzeugkatalog angewendet, das war
|
||||
die von den beiden am dringendsten wachsende Tabelle.
|
||||
|
||||
Der zentrale Katalog ist aktuell mit 20 recherchierten Einträgen
|
||||
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
|
||||
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
|
||||
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
|
||||
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
|
||||
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
|
||||
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
|
||||
mindestens einem Eintrag ab. **Claude Code ist bewusst ein eigener
|
||||
Eintrag neben Claude for Work**, kein Duplikat: es läuft lokal auf
|
||||
Entwicklerrechnern mit direktem Zugriff auf Dateien/Codebasen und
|
||||
speichert Sitzungsprotokolle lokal im Klartext (bis zu 30 Tage,
|
||||
konfigurierbar) — ein zusätzliches lokales Aufbewahrungsrisiko, das bei
|
||||
einer reinen Web-Chat-Anwendung wie Claude for Work nicht existiert,
|
||||
obwohl beide über dieselben Commercial Terms/DPA laufen. Jede
|
||||
Zusicherung ist mit einer echten,
|
||||
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
|
||||
zwei Einträge (Adobe Firefly: Trainingsausschluss ist nicht Standard;
|
||||
Otter.ai: Training auf de-identifizierten Aufnahmen, laufende
|
||||
Rechtsstreitigkeiten) sind bewusst mit kritischen Einschränkungen
|
||||
versehen statt schöngefärbt, das ist die vorgesehene Funktion des
|
||||
Katalogs.
|
||||
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.
|
||||
|
||||
**`dpf_zertifiziert` recherchiert (2026-09-01), Offener Punkt erledigt.**
|
||||
Für alle 20 Einträge wurde gezielt gegen das offizielle Register
|
||||
(dataprivacyframework.gov, per Participant-ID einzeln nachvollziehbar)
|
||||
geprüft — nicht gegen die JS-Suchseite selbst (nicht scrapebar), sondern
|
||||
gegen das dahinterliegende, von ihr selbst genutzte API. Ergebnis: 12
|
||||
zertifiziert (Microsoft 365 Copilot, GitHub Copilot Business, Google
|
||||
Gemini, Perplexity Enterprise Pro, Adobe Firefly, Otter.ai Business,
|
||||
Notion AI, Grammarly Business, ElevenLabs, Zendesk AI, Canva Magic
|
||||
Studio, Zapier), 5 recherchiert aber nicht im Register gefunden
|
||||
(ChatGPT Enterprise, Claude for Work, Claude Code, Midjourney, Julius
|
||||
AI — `false` heißt hier jetzt "geprüft, kein Eintrag gefunden", nicht
|
||||
mehr "nicht recherchiert"), 3 nicht anwendbar (DeepL, Le Chat/Mistral,
|
||||
Synthesia — reine EU-Verarbeitung ohne US-Rechtsträger im Register, DPF
|
||||
regelt EU→USA-Transfers). Besonderheiten als `einschraenkungen`-Eintrag
|
||||
je Werkzeug festgehalten: Perplexity hat nur EU-US/UK-, keine
|
||||
Swiss-US-Zertifizierung; Notion AIs Zertifizierung ist als "Active" mit
|
||||
bereits abgelaufenem Datum in Re-Zertifizierung; Grammarly ist im
|
||||
Register unter dem fusionierten Rechtsträger "Superhuman Platform Inc."
|
||||
gelistet, nicht mehr unter "Grammarly, Inc.".
|
||||
|
||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||
@@ -316,8 +412,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.
|
||||
@@ -369,13 +465,16 @@ Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
|
||||
|
||||
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
|
||||
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
|
||||
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
|
||||
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
|
||||
3). Zum Zeitpunkt dieser Umsetzung gab es noch keine E-Mail-Infrastruktur
|
||||
(die kam erst 2026-09-01 mit `internal/mail`, siehe „Passwort-
|
||||
Zurücksetzen und E-Mail-Versand" weiter unten, bisher nur für
|
||||
Passwort-Zurücksetzen genutzt) — "Benachrichtigung" ist deshalb bewusst
|
||||
weiterhin eine In-App-Liste, kein E-Mail-Versand. Eine aktive Genehmigung erscheint dort,
|
||||
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
|
||||
@@ -395,6 +494,222 @@ das Register ist eine Genehmigungsliste, kein vollständiges Antragslog
|
||||
|
||||
---
|
||||
|
||||
## Freigabe-Workflow (konfigurierbare Mehrfach-Genehmigung, 2026-08-31)
|
||||
|
||||
**Bewusste Abkehr von einer dokumentierten v1-Ausschlussentscheidung.**
|
||||
Diese CLAUDE.md schloss "konfigurierbare Rollen mit eigener Oberfläche"
|
||||
und "Workflow-Designer" ursprünglich explizit für v1 aus (siehe "Nicht
|
||||
bauen (v1)" unten). Der Nutzer hat das am 2026-08-31 bewusst und nach
|
||||
Rückfrage aufgehoben ("Wir sollten das System schon perfekt bauen! Daher
|
||||
auch gerne komplex") — Auslöser war die Frage, ob ein Datenschutz-
|
||||
beauftragter und/oder die Geschäftsführung zusätzlich in die Entscheidung
|
||||
eingebunden werden müssen. Statt DSB/GF fest zu verdrahten, können
|
||||
Mandanten jetzt selbst definieren, wer bei welcher abgeleiteten
|
||||
Bedingung zusätzlich zustimmen muss.
|
||||
|
||||
**Datenmodell (Migration 0017):** vier neue Tabellen, orthogonal zu den
|
||||
fünf bestehenden Zugriffsrollen (`mitarbeiter`/`verantwortlicher`/
|
||||
`pruefer`/`admin`/`betreiber` bleiben unverändert):
|
||||
|
||||
- `genehmiger_rolle` — eine mandantendefinierte Freigabe-Funktion (z. B.
|
||||
"Datenschutzbeauftragter", "Geschäftsführung"). Kein Zugriffsrecht,
|
||||
nur eine Freigabe-Zuständigkeit.
|
||||
- `nutzer_genehmiger_rolle` — Zuordnung Person↔Genehmiger-Rolle,
|
||||
many-to-many. Eine Person kann null, eine oder mehrere davon haben,
|
||||
unabhängig von ihrer `app_user.role`.
|
||||
- `freigabe_regel` — "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||
Freigabe durch Genehmiger-Rolle Y nötig". Die Bedingung ist bewusst
|
||||
**kein freier Regeleditor**, sondern auf die drei Vokabulare
|
||||
beschränkt, die die Bewertungslogik ohnehin schon berechnet:
|
||||
Anforderungs-ID (z. B. `dsfa_erforderlich`), Einstufungs-ID (z. B.
|
||||
`hochrisiko`) oder Datenklasse-ID (z. B. `besondere_kategorie`).
|
||||
Kompromiss zwischen "konfigurierbar" (Nutzeranforderung) und
|
||||
"buildbar/auditierbar" (jede mögliche Bedingung ist bereits eine
|
||||
geprüfte, bekannte Ableitung, keine beliebige Freitext-Regel).
|
||||
- `freigabeschritt` — pro Antrag ein Datensatz je ausgelöster
|
||||
Freigabe-Regel, Status `ausstehend`/`genehmigt`/`abgelehnt`.
|
||||
|
||||
`antrag.status` hat einen neuen Zwischenwert `wartet_auf_freigabe`
|
||||
zwischen `eingereicht` und `entschieden` (CHECK-Constraint erweitert).
|
||||
|
||||
**Ablauf:** `handleFallEntscheiden` (`internal/web/fachebene_handlers.go`)
|
||||
prüft nach einer Genehmigung/Genehmigung-mit-Auflagen, ob die Bewertung
|
||||
mindestens eine `freigabe_regel` des Mandanten auslöst
|
||||
(`triggeredGenehmigerRollen`). Falls ja: `antrag.status` wird
|
||||
`wartet_auf_freigabe` statt `entschieden`, pro ausgelöster Regel entsteht
|
||||
ein `freigabeschritt`, **kein** `registereintrag` entsteht noch. Falls
|
||||
keine Regel greift, läuft exakt der alte Pfad (`registriereGenehmigung`)
|
||||
— **verifiziert per dediziertem Test**
|
||||
(`TestOhneFreigabeRegelnVerhaeltSichWieVorher`), da alle heutigen
|
||||
Bestandskunden null Freigabe-Regeln haben und sich für sie nichts ändern
|
||||
darf.
|
||||
|
||||
**Bestätigte Semantik (2026-08-31, Nutzer: "Beides stimmt!"):**
|
||||
1. **Alle** ausgelösten Genehmiger-Rollen müssen zustimmen, nicht nur
|
||||
eine von mehreren — geprüft durch erneutes `ListFreigabeschritteForAntrag`
|
||||
nach jeder Einzelentscheidung.
|
||||
2. **Eine Ablehnung kippt den gesamten Antrag**, geht nicht zurück an
|
||||
die Fachebene zur Neuentscheidung. `KaskadiereAblehnung` setzt dabei
|
||||
automatisch alle anderen noch `ausstehend`en Freigabeschritte
|
||||
desselben Antrags ebenfalls auf `abgelehnt` — sonst würde ein
|
||||
Freigabeschritt in der Liste einer anderen Person ewig unbearbeitet
|
||||
hängen bleiben, obwohl der Antrag längst entschieden ist.
|
||||
|
||||
Eine Ablehnung (ob direkt oder per Kaskade) mutiert **nicht** die
|
||||
ursprüngliche `genehmigt`-Zeile — `entscheidung` bleibt append-only wie
|
||||
bisher. Stattdessen entsteht eine neue `entscheidung`-Zeile mit
|
||||
`Entscheidung: "abgelehnt"`; `GetLatestEntscheidungForAntrag` (bereits
|
||||
bestehender "letzte Zeile gewinnt"-Helfer) zeigt den Override überall
|
||||
dort, wo er ohnehin schon verwendet wird — keine weitere Codeänderung
|
||||
nötig, um die Überschreibung sichtbar zu machen.
|
||||
|
||||
**Frontend** (`internal/web/freigabe_handlers.go` + drei neue Templates):
|
||||
`GET/POST /verwaltung/genehmiger-rollen` (Admin: Rolle anlegen, Mitglieder
|
||||
zuordnen/entfernen — Formular nutzt einen einzelnen `<select>` mit drei
|
||||
`<optgroup>`s für die drei Bedingungstypen, kein abhängiges Dropdown,
|
||||
kein JavaScript), `GET/POST /verwaltung/freigabe-regeln` (Admin: Regel
|
||||
anlegen/löschen), `GET /freigaben` + `POST /freigaben/{id}/entscheiden`
|
||||
(jeder eingeloggte Nicht-Betreiber mit zugeordneter Genehmiger-Rolle:
|
||||
eigene offene Freigaben sehen und entscheiden, mit Pflicht-Kommentarfeld
|
||||
bei Ablehnung). Fall-Detail (`fall_detail.html`) zeigt die
|
||||
Freigabeschritt-Historie mit Ampel-Badge **unabhängig vom aktuellen
|
||||
Antragsstatus** — anfangs fälschlich auf `wartet_auf_freigabe`
|
||||
beschränkt gebaut, beim Live-Verifizieren aufgefallen: sobald der letzte
|
||||
Freigebende zustimmt und der Antrag auf `entschieden` springt, verschwand
|
||||
die komplette Freigabehistorie aus der Ansicht, was der Grundregel
|
||||
widerspricht, dass eine Entscheidung nie ohne vollständige Herleitung
|
||||
gezeigt wird. Der Hinweistext "Genehmigung erst endgültig, wenn alle
|
||||
erteilt sind" erscheint entsprechend nur noch, solange der Antrag
|
||||
tatsächlich noch auf Freigabe wartet.
|
||||
|
||||
**CSS-Bugfix, beim Live-Verifizieren gefunden (sitesweit, nicht nur
|
||||
diese Seite betreffend):** `.beitraege-liste > li:not(:has(> a)) form`
|
||||
hatte `flex: 0 0 auto` (kein Schrumpfen). Bei den bisherigen Listen
|
||||
(Nutzer, Abteilungen) enthielten diese eingebetteten Formulare nur
|
||||
Buttons, die naturgemäß schmal bleiben — die Genehmiger-Rollen-Seite ist
|
||||
die erste mit einem `<select>` darin, dessen Browser-Eigenbreite
|
||||
(bestimmt durch die längste Options-Beschriftung, hier E-Mail-Adressen)
|
||||
den ganzen Zeilen-Container über die Mobile-Viewport-Breite hinaustrieb
|
||||
(horizontales Scrollen der ganzen Seite). Fix: `max-width: 100%` auf
|
||||
derselben Regel ergänzt — generisch, wirkt auf jedes künftige
|
||||
`<select>` in diesem Listen-Pattern, nicht nur hier.
|
||||
|
||||
**Live end-to-end verifiziert** (curl gegen echten lokalen Server +
|
||||
echtes Postgres, nicht nur die Go-Testsuite): Happy Path (eine Regel,
|
||||
eine Rolle, Genehmigung → `wartet_auf_freigabe` → Freigabe erteilt →
|
||||
`entschieden` + 1 Registereintrag) und Kaskaden-Ablehnung (zwei Regeln,
|
||||
zwei Rollen, eine lehnt ab → beide Freigabeschritte `abgelehnt`, Antrag
|
||||
direkt `entschieden` als `abgelehnt`, 0 Registereinträge) — beide exakt
|
||||
wie spezifiziert. Chromium-Headless-Screenshots (Desktop 1440×900 +
|
||||
Mobile 390×844) aller vier neuen/geänderten Seiten bestätigen zusätzlich
|
||||
das responsive Layout nach dem enconf-Card-Pattern.
|
||||
|
||||
**Standard-Genehmiger-Rollen bei Firmenanlage (2026-08-31, Migration
|
||||
0018).** Jede neue Firma bekommt jetzt automatisch vier leere
|
||||
Genehmiger-Rollen mit erklärender Beschreibung (`standardGenehmigerRollen`
|
||||
in `internal/web/freigabe_handlers.go`, aufgerufen aus `handleRegister`
|
||||
und `handleBetreiberAccountCreate` direkt nach `CreateAccount`):
|
||||
|
||||
- **Datenschutzbeauftragter** — "Prüft Anträge mit besonderen
|
||||
Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung
|
||||
erforderlich ist."
|
||||
- **Geschäftsführer** — "Trifft die unternehmerische Entscheidung bei
|
||||
besonders risikobehafteten Vorhaben."
|
||||
- **KI-Manager** — "Bewertet den Antrag fachlich."
|
||||
- **CISO** — "Prüft den Antrag aus Sicherheitssicht (Auditor)."
|
||||
|
||||
Bewusst **ohne** Mitglieder und **ohne** Freigabe-Regel — der Admin muss
|
||||
nur noch Personen zuordnen (`/verwaltung/genehmiger-rollen`) und
|
||||
optional eine auslösende Bedingung definieren
|
||||
(`/verwaltung/freigabe-regeln`), statt bei null anzufangen. Welche
|
||||
Bedingung welche Rolle tatsächlich auslöst, bleibt **vollständig
|
||||
Sache der Firma** — das System schreibt keine feste fachliche Bindung
|
||||
vor (z. B. keine hartkodierte Kopplung "Geschäftsführer ⇒ hochrisiko");
|
||||
die Beschreibung ist reine UI-Orientierungshilfe, kein Ableitungsregel-
|
||||
Ersatz. `genehmiger_rolle.beschreibung` ist ein freies, optionales
|
||||
Textfeld (`NOT NULL DEFAULT ''`) — auch für vom Admin selbst angelegte
|
||||
zusätzliche Rollen nutzbar. Getestet via `TestRegisterSeedsStandardGenehmigerRollen`
|
||||
und live per curl gegen echten Server + Postgres verifiziert.
|
||||
|
||||
---
|
||||
|
||||
## Löschfristen je Datenklasse (2026-09-01, Migration 0020)
|
||||
|
||||
Schließt `rules/OPEN.md`, Punkt 4. Recherche ergab: die DSGVO nennt
|
||||
**keine** festen Speicherfristen (Art. 5 Abs. 1 lit. e verlangt nur "so
|
||||
lange wie für den Zweck nötig") — es gibt also keinen fest im Regelwerk
|
||||
hinterlegbaren "richtigen" Zahlenwert. Lösung: **pro Mandant einstellbar**
|
||||
(`loeschfrist_einstellung`, `GET/POST /verwaltung/loeschfristen`,
|
||||
admin-only wie jede andere `/verwaltung/*`-Seite — bewusst NICHT an den
|
||||
Namen der Genehmiger-Rolle "Datenschutzbeauftragter" gekoppelt, siehe
|
||||
Freigabe-Workflow oben: Genehmiger-Rollen sind explizit ohne eigenes
|
||||
Zugriffsrecht). Jede neue Firma wird mit risikogestaffelten
|
||||
Vorschlagswerten vorbelegt (`seedStandardLoeschfristen`, aufgerufen wie
|
||||
`seedStandardGenehmigerRollen` direkt nach `CreateAccount`):
|
||||
`oeffentlich` 365 / `intern` 180 / `auftragsdaten` 90 / `personenbezogen`
|
||||
90 / `berufsgeheimnis` 30 / `besondere_kategorie` 30 Tage — eigene
|
||||
Einschätzung nach Sensibilität gestaffelt, **keine Rechtsvorgabe**, frei
|
||||
editierbar (Formular-Hinweistext sagt das ausdrücklich).
|
||||
|
||||
**Harte Filterung jetzt aktiv** (vorher, siehe `rules/OPEN.md` Punkt 4
|
||||
alt, wurde `loeschfrist_max_tage` gar nicht geprüft): `ErfuelltAnforderung`
|
||||
und `FilterWerkzeuge` (`internal/rules/evaluate.go`) haben einen neuen
|
||||
Parameter `loeschfristMaxTage *int`. `nil` (keine Frist konfiguriert)
|
||||
verhält sich wie vorher (nicht gefiltert) — Rückwärtskompatibilität.
|
||||
Ist eine Frist gesetzt, erfüllt ein Werkzeug ohne bezifferte
|
||||
Aufbewahrungsdauer (`werkzeug.aufbewahrung_tage IS NULL`) sie NICHT
|
||||
(fail closed, analog zur leeren Länderliste bei `eu_verarbeitung`) —
|
||||
dafür wurde `rules.WerkzeugEigenschaften.AufbewahrungTage` von `int` auf
|
||||
`*int` geändert, sonst wäre "unbekannt" nicht von "0 Tage" unterscheidbar
|
||||
gewesen. `internal/web/antrag_handlers.go:bewerteAntrag` liest die
|
||||
Einstellung für die aktuelle Datenklasse des Antrags aus
|
||||
`ListLoeschfristEinstellungenForAccount` und reicht sie durch.
|
||||
|
||||
---
|
||||
|
||||
## Passwort-Zurücksetzen und E-Mail-Versand (2026-09-01)
|
||||
|
||||
Neues Paket `internal/mail`: `Mailer`-Interface (`Send(to, subject, body)
|
||||
error`), `SMTPMailer` als echte Implementierung (stdlib `net/smtp`,
|
||||
STARTTLS falls vom Server angeboten — deckt den Standard-Submission-Port
|
||||
587 ab, implizites TLS auf Port 465 wird von `net/smtp` nicht
|
||||
unterstützt) und `FakeMailer` als Test-Doppel (sammelt gesendete Mails
|
||||
statt sie zu verschicken). Konfiguration über `SMTP_HOST`/`SMTP_PORT`/
|
||||
`SMTP_USER`/`SMTP_PASSWORD`/`SMTP_FROM` (alle optional — ohne
|
||||
`SMTP_HOST` startet der Dienst normal, nur der tatsächliche Versand
|
||||
schlägt dann mit einem klaren Fehler fehl statt einer nie ankommenden
|
||||
Mail vorzutäuschen; bewusst kein stiller No-op).
|
||||
|
||||
**Erste Anwendung: Passwort-Zurücksetzen** (Ebene 1, öffentlich, bisher
|
||||
in der Rollentabelle nur als Absicht genannt, nie gebaut). `GET/POST
|
||||
/passwort-vergessen` nimmt eine E-Mail-Adresse entgegen und zeigt
|
||||
**immer** dieselbe Erfolgsmeldung ("Falls diese E-Mail-Adresse bei uns
|
||||
registriert ist, wurde ein Link verschickt") — unabhängig davon, ob die
|
||||
Adresse existiert, aktiv ist, oder der Versand tatsächlich geklappt hat.
|
||||
Das ist derselbe User-Enumeration-Schutz wie beim Login (identische
|
||||
Fehlermeldung bei falscher E-Mail/falschem Passwort); ein Versandfehler
|
||||
wird serverseitig geloggt (`log.Printf`, nicht verschluckt), aber nicht
|
||||
an den Client durchgereicht. `password_reset_token` (Migration 0019):
|
||||
ein Token ist 1 Stunde gültig, genau einmal verwendbar (`used_at`),
|
||||
`GetValidPasswordResetToken` liefert für "nicht gefunden", "abgelaufen"
|
||||
und "schon verwendet" identisch `ErrNotFound` — auch hier kein
|
||||
Unterschied, der einem Angreifer etwas verraten könnte. `GET/POST
|
||||
/passwort-zuruecksetzen/{token}` prüft den Token, setzt bei Erfolg das
|
||||
neue Passwort (`store.SetUserPassword`) und verbraucht den Token.
|
||||
|
||||
**Live end-to-end verifiziert** — nicht nur gegen `mail.FakeMailer` in
|
||||
Tests, sondern gegen einen echten, minimalen SMTP-Server (selbst
|
||||
geschriebener TCP-Listener, der die EHLO/MAIL FROM/RCPT TO/DATA-Sequenz
|
||||
korrekt beantwortet): Registrierung → Anfrage → tatsächlich empfangene
|
||||
Mail mit funktionierendem Link → Formular → neues Passwort → Login mit
|
||||
altem Passwort abgelehnt, mit neuem Passwort erfolgreich. Dabei einen
|
||||
echten Bug gefunden und behoben: der Versandfehler wurde zunächst mit
|
||||
`_ = s.mailer.Send(...)` still verschluckt (Verstoß gegen die
|
||||
"keine stillen Fallbacks"-Regel) — beim Debuggen selbst schmerzhaft
|
||||
aufgefallen, jetzt geloggt statt verworfen.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding
|
||||
|
||||
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
||||
@@ -464,7 +779,137 @@ 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.
|
||||
|
||||
**enconf-Patterns übertragen (2026-08-31):** enconf (Schwesterprojekt)
|
||||
ist React + Ant Design (`ProTable`/`StatCard`/`PageHeader`-Komponenten)
|
||||
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die
|
||||
*Muster* wurden als reines CSS/HTML nachgebaut (recherchiert über die
|
||||
Architect-RAG-Suche, `ac_search_code_global`, da das enconf-Repo selbst
|
||||
außerhalb des Arbeitsverzeichnisses liegt):
|
||||
|
||||
- **`.table-responsive`** (ersetzt `.table-scroll` schrittweise) —
|
||||
enconfs `ProTable.tsx` schaltet auf schmalen Screens von Tabelle auf
|
||||
eine Card-Liste um (`renderMobileCard` via `Grid.useBreakpoint()`),
|
||||
statt horizontal zu scrollen. Nachgebaut rein in CSS: unter 640px
|
||||
werden `<tr>` zu Karten, `<td>` zu Label/Wert-Zeilen (`content:
|
||||
attr(data-label)` im `::before`) — jedes `<td>` braucht dafür ein
|
||||
`data-label="Spaltenname"`-Attribut. Ab 640px normale Tabelle mit
|
||||
Scroll-Fade als Sicherheitsnetz. Bisher angewendet auf beide
|
||||
Werkzeugkatalog-Tabellen; `.table-scroll` (nur Scroll-Fade, keine
|
||||
Card-Umschaltung) bleibt für noch nicht migrierte Tabellen (Register,
|
||||
Nutzer-Import-Ergebnis) bestehen.
|
||||
- **`.page-header`** (Titel+Unterzeile links, primäre Aktion rechts,
|
||||
stapelt auf Mobile) — ersetzt einen nackten `<h1>` mit einem Link
|
||||
irgendwo unten auf der Seite. Bisher auf den Werkzeugkatalog
|
||||
angewendet (Betreiber + Mandant, inkl. "Eigene Werkzeuge"-Abschnitt).
|
||||
- **`.stat-cards`/`.stat-card`** — Kennzahl-Kacheln mit farbigem oberem
|
||||
Akzentrand (blau normal, gelb/rot nur wenn der Wert > 0 ist — keine
|
||||
Farbe ohne Grund) statt einfacher Text-Links; `.stat-card-action` für
|
||||
die primäre Handlungs-Kachel (z. B. "Antrag stellen"). Kein
|
||||
Icon-Set/keine Icon-Bibliothek eingeführt (enconf nutzt
|
||||
`@ant-design/icons`, das ist React-spezifisch) — bewusste
|
||||
Vereinfachung. Angewendet auf die Startseite (`index.html`).
|
||||
|
||||
**Ausgerollt (2026-08-31):** Register, Nutzerverwaltung, Abteilungen,
|
||||
Fälle-Posteingang, Betreiber-Dashboard.
|
||||
|
||||
- **Register** (`register_liste.html`) und **Fälle-Posteingang**
|
||||
(`faelle_liste.html`) haben jetzt `.page-header` + Freitext-Filter
|
||||
(`q`) + Paginierung. Register zusätzlich `.table-responsive` (war
|
||||
vorher `.table-scroll`) mit `data-label` je `<td>`.
|
||||
- **Nutzerverwaltung** (`nutzer_liste.html`) hat `.page-header` +
|
||||
Freitext-Filter (E-Mail/Rolle) + Paginierung — blieb `.beitraege-liste`
|
||||
(schon Karten-artig), keine `.table-responsive`-Umstellung nötig.
|
||||
- **Abteilungen** (`abteilung_liste.html`) bekam nur `.page-header` —
|
||||
bewusst ohne Paginierung, Abteilungslisten sind typischerweise klein
|
||||
(Handvoll Stammdaten pro Mandant).
|
||||
- **Betreiber-Dashboard** (`betreiber_dashboard.html`) von
|
||||
`.admin-kacheln` auf `.stat-cards` umgestellt (analog zu `index.html`).
|
||||
|
||||
Neue gemeinsame Helfer in `pagination.go`: `matchesQuery(q string,
|
||||
felder ...string) bool` — eine einzige Freitext-Filter-Basis für alle
|
||||
Listen-Seiten, damit nicht jede ihre eigene Such-Logik erfindet.
|
||||
|
||||
**Beim Ausrollen gefundener, bereits live gewesener Bug:** `.filter-bar`
|
||||
direkt auf einem `<form>`-Element (statt auf einem `<div>` innerhalb
|
||||
des Formulars) verlor gegen die allgemeine `form { flex-direction:
|
||||
column }`-Regel — beide Selektoren haben für die Property
|
||||
`flex-direction` dieselbe Spezifität, aber nur `form` setzte sie
|
||||
explizit, `.filter-bar` überließ sie dem Initialwert. Ergebnis: die
|
||||
Filterleiste stapelte senkrecht statt in einer Zeile. Betraf bereits
|
||||
die im vorigen Schritt ausgelieferte Mandanten-Werkzeugkatalog-Seite.
|
||||
Behoben durch explizites `flex-direction: row` in `.filter-bar` selbst
|
||||
— jetzt unabhängig davon robust, ob die Klasse auf einem `<form>` oder
|
||||
einem `<div>` sitzt.
|
||||
|
||||
**Ebenfalls ausgerollt (2026-08-31, zweite Runde):** Betreiber-Accounts
|
||||
(`.page-header` + Filter über Firmenname + Paginierung), Betreiber-
|
||||
Audit-Log (`.page-header` + Filter über Aktion/Zieltyp/Details +
|
||||
Paginierung), "Meine Anträge" (`.page-header` + Filter über Titel/
|
||||
Status/Häufigkeit + Paginierung), Wiedervorlage (nur `.page-header`,
|
||||
bewusst ohne Paginierung — natürlich begrenzt auf "braucht gerade
|
||||
Aufmerksamkeit").
|
||||
|
||||
**Bekannte Einschränkung beim Audit-Log:** `ListAuditLog` holt weiterhin
|
||||
nur die neuesten `auditLogFetchLimit` (1000) Einträge aus der DB, dann
|
||||
wird in Go gefiltert/paginiert (`auditLogFetchLimit`-Konstante in
|
||||
`betreiber_handlers.go`) — keine echte DB-seitige Offset-Paginierung.
|
||||
Für ein potenziell sehr groß werdendes, append-only Audit-Log ist das
|
||||
mittelfristig nicht die saubere Lösung (Einträge jenseits der 1000
|
||||
neuesten sind über die Such-/Paginierungs-UI nicht erreichbar), aber
|
||||
proportional zum aktuellen Datenvolumen und konsistent mit dem
|
||||
In-Memory-Paginierungsmuster der übrigen Listen. Bei echtem Bedarf:
|
||||
`store.ListAuditLog` um `offset`/echtes SQL-`LIMIT`/`OFFSET` erweitern.
|
||||
|
||||
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||
@@ -605,12 +1050,22 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
|
||||
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
|
||||
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
|
||||
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
|
||||
8. ~~Konfigurierbarer Freigabe-Workflow (Mehrfach-Genehmigung)~~ —
|
||||
**erledigt** (siehe "Freigabe-Workflow" weiter oben: Genehmiger-Rollen,
|
||||
Freigabe-Regeln, Freigabeschritte, Migration 0017 — bewusste Abkehr
|
||||
von der ursprünglichen v1-Ausschlussentscheidung, siehe "Nicht bauen
|
||||
(v1)" unten).
|
||||
|
||||
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
||||
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
|
||||
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
|
||||
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
|
||||
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
|
||||
Nutzung einzelner Personen auswertet), Konzernstrukturen mit Vererbung,
|
||||
mandantenspezifische Regelwerke, SAML, Schnittstellen zu Fremdsystemen.
|
||||
|
||||
~~konfigurierbare Rollen mit eigener Oberfläche~~ / ~~Workflow-Designer~~
|
||||
— **am 2026-08-31 bewusst aufgehoben**, siehe "Freigabe-Workflow"
|
||||
weiter oben: Genehmiger-Rollen und Freigabe-Regeln sind jetzt genau das,
|
||||
nur mit der Bedingung auf die drei bereits von der Bewertungslogik
|
||||
berechneten Vokabulare beschränkt (kein beliebiger Regeleditor).
|
||||
|
||||
---
|
||||
|
||||
@@ -831,8 +1286,9 @@ journalctl -u deklarix -f
|
||||
`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.
|
||||
- ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe
|
||||
„Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar
|
||||
statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt.
|
||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
||||
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
||||
@@ -854,15 +1310,13 @@ journalctl -u deklarix -f
|
||||
"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`
|
||||
anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST
|
||||
.../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`.
|
||||
Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein
|
||||
mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar
|
||||
[404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma-
|
||||
Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist
|
||||
inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge
|
||||
pflegt.
|
||||
- ~~Zentraler Werkzeugkatalog muss noch befüllt werden~~ — **erledigt**,
|
||||
20 recherchierte Einträge seit 2026-08-31 (siehe „Werkzeugkatalog"
|
||||
weiter oben). ~~`dpf_zertifiziert` recherchieren~~ — **erledigt
|
||||
2026-09-01** (siehe dort für Details je Anbieter). "Werkzeugkatalog aus
|
||||
der Plattform vorbelegt" beim Firma-Onboarding bleibt weiterhin nicht
|
||||
automatisiert — jede neue Firma sieht sofort den vollen zentralen
|
||||
Katalog (kein Kopiervorgang nötig, `account_id IS NULL` ist für alle
|
||||
Mandanten gleich sichtbar), es gibt nur keine mandantenspezifische
|
||||
Vorauswahl/Kuratierung beim Onboarding.
|
||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
@@ -51,9 +52,17 @@ func main() {
|
||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||
|
||||
mailer := mail.NewSMTPMailer(mail.Config{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: os.Getenv("SMTP_PORT"),
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: os.Getenv("SMTP_FROM"),
|
||||
})
|
||||
|
||||
server, err := web.NewServer(db, web.Regelwerk{
|
||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||
})
|
||||
}, mailer)
|
||||
if err != nil {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
|
||||
78
internal/mail/mail.go
Normal file
78
internal/mail/mail.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package mail versendet E-Mails (aktuell: Passwort-Zurücksetzen-Links)
|
||||
// über einen konfigurierten SMTP-Server. Reine Versandlogik, kein
|
||||
// Template-Rendering — der Aufrufer (internal/web) baut Betreff/Text
|
||||
// selbst zusammen, damit dieses Paket unabhängig vom Web-Layer bleibt.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
// Mailer ist die Schnittstelle, die internal/web nutzt — austauschbar
|
||||
// gegen eine Test-Doppel (siehe FakeMailer), damit Tests keinen echten
|
||||
// SMTP-Server brauchen.
|
||||
type Mailer interface {
|
||||
Send(to, subject, body string) error
|
||||
}
|
||||
|
||||
// Config sind die SMTP-Zugangsdaten, aus Umgebungsvariablen gelesen
|
||||
// (siehe cmd/deklarix/main.go) — kein neues Geheimnis im Code.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
// SMTPMailer versendet über einen echten SMTP-Server (STARTTLS, falls
|
||||
// vom Server angeboten — das deckt den Standard-Submission-Port 587 ab;
|
||||
// implizites TLS auf Port 465 wird von net/smtp nicht unterstützt).
|
||||
type SMTPMailer struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func NewSMTPMailer(cfg Config) *SMTPMailer {
|
||||
return &SMTPMailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send verschickt eine reine Text-E-Mail. Liefert einen Fehler, wenn
|
||||
// kein SMTP-Host konfiguriert ist — bewusst kein stiller No-op, ein
|
||||
// fehlgeschlagener Versand (z. B. Passwort-Reset) muss sichtbar
|
||||
// scheitern statt eine E-Mail vorzutäuschen, die nie ankommt.
|
||||
func (m *SMTPMailer) Send(to, subject, body string) error {
|
||||
if m.cfg.Host == "" {
|
||||
return fmt.Errorf("mail: SMTP nicht konfiguriert (SMTP_HOST fehlt)")
|
||||
}
|
||||
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||
msg := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
m.cfg.From, to, subject, body,
|
||||
)
|
||||
|
||||
var auth smtp.Auth
|
||||
if m.cfg.User != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
||||
}
|
||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil {
|
||||
return fmt.Errorf("mail: senden an %s: %w", to, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SentMail protokolliert einen Versand für Tests.
|
||||
type SentMail struct {
|
||||
To, Subject, Body string
|
||||
}
|
||||
|
||||
// FakeMailer ist das Test-Doppel für Mailer — sammelt gesendete Mails
|
||||
// statt sie zu verschicken.
|
||||
type FakeMailer struct {
|
||||
Sent []SentMail
|
||||
}
|
||||
|
||||
func (f *FakeMailer) Send(to, subject, body string) error {
|
||||
f.Sent = append(f.Sent, SentMail{To: to, Subject: subject, Body: body})
|
||||
return nil
|
||||
}
|
||||
@@ -186,11 +186,50 @@ 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 ist nil, wenn der Anbieter keine Aufbewahrungsdauer
|
||||
// beziffert — das erfüllt eine gesetzte Löschfrist-Anforderung NICHT
|
||||
// (fail closed), analog zur leeren Länderliste bei eu_verarbeitung.
|
||||
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
|
||||
@@ -198,17 +237,30 @@ type WerkzeugEigenschaften struct {
|
||||
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich
|
||||
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
||||
// ebenfalls noch nicht hart gefiltert.
|
||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||
// Werkzeug selbst.
|
||||
//
|
||||
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
|
||||
// konfigurierte Frist (siehe store.LoeschfristEinstellung) — nil
|
||||
// bedeutet "für diesen Mandanten/diese Datenklasse nicht konfiguriert"
|
||||
// und wird NICHT hart gefiltert (Rückwärtskompatibilität, siehe
|
||||
// rules/OPEN.md, Punkt 4); ist eine Frist gesetzt, erfüllt ein Werkzeug
|
||||
// ohne bezifferte Aufbewahrungsdauer sie NICHT (fail closed).
|
||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften, loeschfristMaxTage *int) bool {
|
||||
switch anforderungID {
|
||||
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
|
||||
case "loeschfrist_max_tage":
|
||||
if loeschfristMaxTage == nil {
|
||||
return true
|
||||
}
|
||||
if w.AufbewahrungTage == nil {
|
||||
return false
|
||||
}
|
||||
return *w.AufbewahrungTage <= *loeschfristMaxTage
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -230,12 +282,13 @@ type WerkzeugKandidat struct {
|
||||
|
||||
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||
// Werkzeug fest, welche Anforderungen fehlten.
|
||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||
// Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
|
||||
// ErfuelltAnforderung.
|
||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||
for _, k := range kandidaten {
|
||||
var fehlend []string
|
||||
for _, reqID := range anforderungIDs {
|
||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
|
||||
fehlend = append(fehlend, reqID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,26 +150,62 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}) {
|
||||
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||
}
|
||||
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}) {
|
||||
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}, nil) {
|
||||
t.Error("erwartet: erfuellt mit AVV")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}, nil) {
|
||||
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}, nil) {
|
||||
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"}}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungLoeschfrist(t *testing.T) {
|
||||
dreissig := 30
|
||||
neunzig := 90
|
||||
// Keine Frist konfiguriert -> nicht hart gefiltert (Rückwärtskompatibilität).
|
||||
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("erwartet: erfuellt ohne konfigurierte Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug bleibt innerhalb -> erfuellt.
|
||||
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &dreissig}, &neunzig) {
|
||||
t.Error("erwartet: erfuellt, wenn Aufbewahrung <= Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug überschreitet -> nicht erfuellt.
|
||||
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &neunzig}, &dreissig) {
|
||||
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrung > Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug beziffert Aufbewahrung nicht -> fail closed.
|
||||
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, &dreissig) {
|
||||
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrungsdauer unbekannt ist ('wissen wir nicht' darf nicht als 'ist okay' durchgehen)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}, nil)
|
||||
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||
}
|
||||
@@ -183,7 +219,7 @@ func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||
|
||||
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil)
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil, nil)
|
||||
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||
}
|
||||
|
||||
388
internal/store/freigabe.go
Normal file
388
internal/store/freigabe.go
Normal file
@@ -0,0 +1,388 @@
|
||||
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
|
||||
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
|
||||
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
|
||||
// sein oder keines von beidem.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type GenehmigerRolle struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
Beschreibung string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
|
||||
// Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
|
||||
// Bedingung, das bleibt Sache von freigabe_regel.
|
||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, name, beschreibung, created_at
|
||||
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if err != nil {
|
||||
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GenehmigerRolle{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||
// Mandanten.
|
||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
var g GenehmigerRolle
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
|
||||
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
|
||||
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
|
||||
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||
FROM genehmiger_rolle gr
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||
WHERE ngr.app_user_id = $1
|
||||
ORDER BY gr.name
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
var g GenehmigerRolle
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
|
||||
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
|
||||
type GenehmigerMitglied struct {
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||
// bestimmten Genehmiger-Rolle.
|
||||
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT u.id, u.email
|
||||
FROM app_user u
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||
WHERE ngr.genehmiger_rolle_id = $1
|
||||
ORDER BY u.email
|
||||
`, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerMitglied
|
||||
for rows.Next() {
|
||||
var m GenehmigerMitglied
|
||||
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
|
||||
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
|
||||
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
|
||||
type FreigabeRegel struct {
|
||||
ID string
|
||||
AccountID string
|
||||
BedingungTyp string
|
||||
BedingungWert string
|
||||
GenehmigerRolleID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
|
||||
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []FreigabeRegel
|
||||
for rows.Next() {
|
||||
var f FreigabeRegel
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
|
||||
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FreigabeRegel{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
|
||||
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
|
||||
// append-only — ein Schritt geht von "ausstehend" in genau einen
|
||||
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
|
||||
// Schritt.
|
||||
type Freigabeschritt struct {
|
||||
ID string
|
||||
AntragID string
|
||||
GenehmigerRolleID string
|
||||
Status string // ausstehend/genehmigt/abgelehnt
|
||||
EntschiedenVon *string
|
||||
EntschiedenAm *time.Time
|
||||
Kommentar string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, antragID, genehmigerRolleID).Scan(
|
||||
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Freigabeschritt{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||
`, antragID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Freigabeschritt
|
||||
for rows.Next() {
|
||||
var f Freigabeschritt
|
||||
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
|
||||
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||
// innehat — Grundlage für "Meine Freigaben".
|
||||
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||
FROM freigabeschritt f
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
|
||||
ORDER BY f.created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Freigabeschritt
|
||||
for rows.Next() {
|
||||
var f Freigabeschritt
|
||||
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
|
||||
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
|
||||
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
|
||||
// entschiedenen Schritten wäre irreführend, deshalb hier ein
|
||||
// generischer Fehler statt eines Sentinels).
|
||||
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||
WHERE id = $1 AND status = 'ausstehend'
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, id, status, entschiedenVon, kommentar).Scan(
|
||||
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Freigabeschritt{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
|
||||
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
|
||||
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
|
||||
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
|
||||
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||
// hängen zu bleiben.
|
||||
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = 'abgelehnt', entschieden_am = now(),
|
||||
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
|
||||
`, antragID, ausloesenderSchrittID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
174
internal/store/freigabe_test.go
Normal file
174
internal/store/freigabe_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
|
||||
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
|
||||
}
|
||||
|
||||
got, err := s.GetGenehmigerRolle(ctx, g.ID)
|
||||
if err != nil || got.Name != "Datenschutzbeauftragter" {
|
||||
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
|
||||
if err != nil || len(liste) != 1 {
|
||||
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
|
||||
}
|
||||
|
||||
user := testUserID(t, s, accID)
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
|
||||
// kein Fehler.
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
|
||||
}
|
||||
|
||||
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
|
||||
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
|
||||
}
|
||||
|
||||
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
|
||||
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
|
||||
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
|
||||
}
|
||||
|
||||
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||
if err != nil || len(rollenNachEntfernen) != 0 {
|
||||
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
|
||||
}
|
||||
|
||||
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
|
||||
t.Fatalf("DeleteGenehmigerRolle: %v", err)
|
||||
}
|
||||
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeRegelCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
|
||||
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||
}
|
||||
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
|
||||
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
|
||||
}
|
||||
|
||||
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
|
||||
if err != nil || len(liste) != 1 {
|
||||
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
|
||||
}
|
||||
|
||||
got, err := s.GetFreigabeRegel(ctx, regel.ID)
|
||||
if err != nil || got.ID != regel.ID {
|
||||
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
|
||||
t.Fatalf("DeleteFreigabeRegel: %v", err)
|
||||
}
|
||||
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
user := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
antragID := antrag.ID
|
||||
|
||||
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
|
||||
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||
}
|
||||
if schrittDSB.Status != "ausstehend" {
|
||||
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
|
||||
}
|
||||
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||
}
|
||||
|
||||
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
|
||||
if err != nil || len(alle) != 2 {
|
||||
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
|
||||
}
|
||||
|
||||
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
|
||||
}
|
||||
if len(ausstehend) != 0 {
|
||||
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
|
||||
}
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
|
||||
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
|
||||
}
|
||||
|
||||
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
|
||||
if err != nil {
|
||||
t.Fatalf("EntscheideFreigabeschritt: %v", err)
|
||||
}
|
||||
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
|
||||
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
|
||||
}
|
||||
|
||||
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
|
||||
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
|
||||
t.Fatalf("KaskadiereAblehnung: %v", err)
|
||||
}
|
||||
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFreigabeschritt: %v", err)
|
||||
}
|
||||
if nachKaskade.Status != "abgelehnt" {
|
||||
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
|
||||
}
|
||||
}
|
||||
69
internal/store/loeschfrist.go
Normal file
69
internal/store/loeschfrist.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Löschfristen je Datenklasse (Migration 0020) — schließt die in
|
||||
// rules/OPEN.md, Punkt 4 dokumentierte Lücke: loeschfrist_max_tage war
|
||||
// bisher nur als Anforderung "vorhanden", ohne Tageswerte, und wurde
|
||||
// deshalb nicht hart gefiltert. Die konkreten Tageswerte sind KEINE
|
||||
// gesetzliche Vorgabe (die DSGVO nennt keine festen Fristen, nur den
|
||||
// Grundsatz "so lange wie für den Zweck nötig", Art. 5 Abs. 1 lit. e) —
|
||||
// deshalb pro Mandant einstellbar statt hartkodiert, vom
|
||||
// Datenschutzbeauftragten hinterlegt. Bei Firmenanlage werden
|
||||
// risikogestaffelte Vorschlagswerte vorbelegt (siehe
|
||||
// internal/web/loeschfrist_handlers.go), frei editierbar.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoeschfristEinstellung struct {
|
||||
ID string
|
||||
AccountID string
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertLoeschfristEinstellung legt eine Einstellung an oder
|
||||
// aktualisiert sie — ein Mandant hat höchstens eine Frist je
|
||||
// Datenklasse (UNIQUE-Constraint).
|
||||
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
||||
var e LoeschfristEinstellung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (account_id, datenklasse_id)
|
||||
DO UPDATE SET max_tage = $3, updated_at = now()
|
||||
RETURNING id, account_id, datenklasse_id, max_tage, updated_at
|
||||
`, accountID, datenklasseID, maxTage).Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt)
|
||||
if err != nil {
|
||||
return LoeschfristEinstellung{}, fmt.Errorf("store: upsert loeschfrist einstellung: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
||||
// Fristen eines Mandanten.
|
||||
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, datenklasse_id, max_tage, updated_at
|
||||
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LoeschfristEinstellung
|
||||
for rows.Next() {
|
||||
var e LoeschfristEinstellung
|
||||
if err := rows.Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan loeschfrist einstellung: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
41
internal/store/loeschfrist_test.go
Normal file
41
internal/store/loeschfrist_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoeschfristEinstellungUpsert(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
e, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 90)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||
}
|
||||
if e.MaxTage != 90 {
|
||||
t.Fatalf("MaxTage = %d, want 90", e.MaxTage)
|
||||
}
|
||||
|
||||
// Erneutes Upsert für dieselbe Datenklasse überschreibt statt zu duplizieren.
|
||||
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 30); err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung (Update): %v", err)
|
||||
}
|
||||
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "besondere_kategorie", 14); err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||
}
|
||||
|
||||
liste, err := s.ListLoeschfristEinstellungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLoeschfristEinstellungenForAccount: %v", err)
|
||||
}
|
||||
if len(liste) != 2 {
|
||||
t.Fatalf("got %d Einstellungen, want 2 (Upsert darf nicht duplizieren)", len(liste))
|
||||
}
|
||||
for _, e := range liste {
|
||||
if e.DatenklasseID == "personenbezogen" && e.MaxTage != 30 {
|
||||
t.Fatalf("personenbezogen MaxTage = %d, want 30 nach Update", e.MaxTage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
|
||||
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
|
||||
|
||||
DROP TABLE freigabeschritt;
|
||||
DROP TABLE freigabe_regel;
|
||||
DROP TABLE nutzer_genehmiger_rolle;
|
||||
DROP TABLE genehmiger_rolle;
|
||||
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
|
||||
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
|
||||
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
|
||||
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
|
||||
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
|
||||
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
|
||||
-- und eine Person kann mehrere davon gleichzeitig innehaben.
|
||||
|
||||
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
|
||||
CREATE TABLE genehmiger_rolle (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, name)
|
||||
);
|
||||
|
||||
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
|
||||
CREATE TABLE nutzer_genehmiger_rolle (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (app_user_id, genehmiger_rolle_id)
|
||||
);
|
||||
|
||||
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
|
||||
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
|
||||
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
|
||||
CREATE TABLE freigabe_regel (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
|
||||
bedingung_wert TEXT NOT NULL,
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
|
||||
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
|
||||
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
|
||||
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
|
||||
CREATE TABLE freigabeschritt (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
|
||||
entschieden_von UUID REFERENCES app_user (id),
|
||||
entschieden_am TIMESTAMPTZ,
|
||||
kommentar TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
|
||||
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
|
||||
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE password_reset_token;
|
||||
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE password_reset_token (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES app_user(id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX password_reset_token_user_id_idx ON password_reset_token(user_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE loeschfrist_einstellung;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE loeschfrist_einstellung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account(id),
|
||||
datenklasse_id TEXT NOT NULL,
|
||||
max_tage INTEGER NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, datenklasse_id)
|
||||
);
|
||||
77
internal/store/password_reset.go
Normal file
77
internal/store/password_reset.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Passwort-Zurücksetzen-Tokens (Migration 0019). Ein Token ist einmal
|
||||
// verwendbar (used_at) und läuft ab (expires_at) — GetValidPasswordResetToken
|
||||
// liefert ErrNotFound für "nicht gefunden", "abgelaufen" und "schon
|
||||
// verwendet" gleichermaßen, damit ein Angreifer über die Fehlermeldung
|
||||
// nichts über den Zustand eines geratenen Tokens lernt.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// PasswordResetTokenDuration ist die Gültigkeitsdauer eines frisch
|
||||
// erzeugten Zurücksetzen-Links.
|
||||
const PasswordResetTokenDuration = 1 * time.Hour
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreatePasswordResetToken legt einen neuen Zurücksetzen-Token für
|
||||
// einen Nutzer an. token muss bereits kryptographisch zufällig erzeugt
|
||||
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
|
||||
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO password_reset_token (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, token, expires_at, used_at, created_at
|
||||
`, userID, token, time.Now().Add(PasswordResetTokenDuration)).Scan(
|
||||
&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return PasswordResetToken{}, fmt.Errorf("store: create password reset token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetValidPasswordResetToken liest einen Token nur, wenn er existiert,
|
||||
// noch nicht abgelaufen und noch nicht verwendet ist.
|
||||
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, token, expires_at, used_at, created_at
|
||||
FROM password_reset_token
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
|
||||
`, token).Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PasswordResetToken{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PasswordResetToken{}, fmt.Errorf("store: get valid password reset token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
|
||||
// Link kein zweites Mal ein Passwort setzen kann.
|
||||
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: mark password reset token used: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/store/password_reset_test.go
Normal file
58
internal/store/password_reset_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestPasswordResetTokenLifecycle(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
tok, err := s.CreatePasswordResetToken(ctx, userID, "test-token-123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasswordResetToken: %v", err)
|
||||
}
|
||||
if tok.UserID != userID || tok.Token != "test-token-123" {
|
||||
t.Fatalf("PasswordResetToken = %+v, unerwartete Werte", tok)
|
||||
}
|
||||
|
||||
got, err := s.GetValidPasswordResetToken(ctx, "test-token-123")
|
||||
if err != nil || got.ID != tok.ID {
|
||||
t.Fatalf("GetValidPasswordResetToken = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := s.MarkPasswordResetTokenUsed(ctx, tok.ID); err != nil {
|
||||
t.Fatalf("MarkPasswordResetTokenUsed: %v", err)
|
||||
}
|
||||
|
||||
// Ein verbrauchter Token gilt nicht mehr als gültig.
|
||||
if _, err := s.GetValidPasswordResetToken(ctx, "test-token-123"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Verbrauch = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Ein unbekannter Token ist von Anfang an ErrNotFound.
|
||||
if _, err := s.GetValidPasswordResetToken(ctx, "existiert-nicht"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err für unbekannten Token = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserPassword(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
if err := s.SetUserPassword(ctx, userID, "neuer-hash"); err != nil {
|
||||
t.Fatalf("SetUserPassword: %v", err)
|
||||
}
|
||||
got, err := s.GetUser(ctx, userID)
|
||||
if err != nil || got.PasswordHash != "neuer-hash" {
|
||||
t.Fatalf("GetUser nach SetUserPassword = %+v, err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,20 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetUserPassword ersetzt den Passwort-Hash eines Nutzers (Passwort-
|
||||
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
||||
// CreateUser.
|
||||
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set user password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||
|
||||
@@ -16,15 +16,27 @@ 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
|
||||
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
|
||||
@@ -40,25 +52,26 @@ type Werkzeug struct {
|
||||
// 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
|
||||
Subprozessoren []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, subprozessoren,
|
||||
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 {
|
||||
@@ -66,8 +79,8 @@ func scanWerkzeug(row interface {
|
||||
}) (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.Subprozessoren,
|
||||
&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
|
||||
@@ -79,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{}
|
||||
}
|
||||
@@ -98,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, subprozessoren,
|
||||
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, $15)
|
||||
) 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.Subprozessoren,
|
||||
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)
|
||||
@@ -123,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, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13,
|
||||
letzte_pruefung = $14, quelle = $15, 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, in.Subprozessoren,
|
||||
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)
|
||||
|
||||
@@ -13,21 +13,21 @@ 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: 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",
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||
@@ -35,9 +37,11 @@ type nutzerListItem struct {
|
||||
}
|
||||
|
||||
type nutzerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Nutzer []nutzerListItem
|
||||
Title string
|
||||
Nav navData
|
||||
Nutzer []nutzerListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
||||
@@ -48,8 +52,16 @@ func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.User
|
||||
for _, u := range users {
|
||||
if matchesQuery(q, u.Email, u.Role) {
|
||||
gefiltert = append(gefiltert, u)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, u := range paginate(gefiltert, pag) {
|
||||
data.Nutzer = append(data.Nutzer, nutzerListItem{
|
||||
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
@@ -289,16 +290,25 @@ 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: aufbewahrungTage,
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||
}}
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||
|
||||
var loeschfristMaxTage *int
|
||||
loeschfristen, err := s.store.ListLoeschfristEinstellungenForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range loeschfristen {
|
||||
if l.DatenklasseID == datenklasse.ID {
|
||||
maxTage := l.MaxTage
|
||||
loeschfristMaxTage = &maxTage
|
||||
}
|
||||
}
|
||||
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs, loeschfristMaxTage)
|
||||
in.ZulaessigeWerkzeuge = zulaessig
|
||||
for _, a := range ausgeschlossen {
|
||||
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||
@@ -334,9 +344,11 @@ type antragListItem struct {
|
||||
}
|
||||
|
||||
type antragListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Antraege []antragListItem
|
||||
Title string
|
||||
Nav navData
|
||||
Antraege []antragListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
||||
@@ -351,8 +363,16 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.Antrag
|
||||
for _, a := range list {
|
||||
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
|
||||
gefiltert = append(gefiltert, a)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, a := range paginate(gefiltert, pag) {
|
||||
data.Antraege = append(data.Antraege, antragListItem{
|
||||
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
|
||||
@@ -248,6 +248,51 @@ func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateFiltertNachLoeschfrist(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
sess := fs.sessions[cookie.Value]
|
||||
accountID := fs.users[sess.UserID].AccountID
|
||||
// fullAntragForm loest Datenklasse "personenbezogen" aus (b1=ja).
|
||||
fs.loeschfristen[accountID] = map[string]int{"personenbezogen": 30}
|
||||
|
||||
fs.werkzeuge["kurze-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "kurze-aufbewahrung", Name: "Kurz-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(14),
|
||||
}
|
||||
fs.werkzeuge["lange-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "lange-aufbewahrung", Name: "Lang-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(90),
|
||||
}
|
||||
fs.werkzeuge["unbekannte-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "unbekannte-aufbewahrung", Name: "Unbekannt-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true,
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "kurze-aufbewahrung" {
|
||||
t.Fatalf("ZulaessigeWerkzeuge = %v, want [kurze-aufbewahrung]", b.ZulaessigeWerkzeuge)
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 2 {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want 2 ausgeschlossene", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
)
|
||||
|
||||
type authPageData struct {
|
||||
Title string
|
||||
Error string
|
||||
Title string
|
||||
Error string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||
@@ -23,7 +24,11 @@ func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||
data := authPageData{Title: "Anmelden"}
|
||||
if r.URL.Query().Get("reset") == "1" {
|
||||
data.Message = "Passwort erfolgreich geändert. Bitte melde dich mit dem neuen Passwort an."
|
||||
}
|
||||
s.renderAuthPage(w, "login", data)
|
||||
}
|
||||
|
||||
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||
@@ -66,6 +71,15 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Löschfristen konnten nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
return
|
||||
|
||||
@@ -2,9 +2,11 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
type betreiberDashboardData struct {
|
||||
@@ -52,9 +54,11 @@ type betreiberAccountListItem struct {
|
||||
}
|
||||
|
||||
type betreiberAccountListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Accounts []betreiberAccountListItem
|
||||
Title string
|
||||
Nav navData
|
||||
Accounts []betreiberAccountListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||
@@ -64,8 +68,16 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.Account
|
||||
for _, a := range accounts {
|
||||
if matchesQuery(q, a.Name) {
|
||||
gefiltert = append(gefiltert, a)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, a := range paginate(gefiltert, pag) {
|
||||
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
@@ -135,6 +147,20 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Genehmiger-Rollen konnten 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.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Löschfristen konnten 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.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
|
||||
@@ -218,20 +244,37 @@ type betreiberAuditEntryView struct {
|
||||
}
|
||||
|
||||
type betreiberAuditLogData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Entries []betreiberAuditEntryView
|
||||
Title string
|
||||
Nav navData
|
||||
Entries []betreiberAuditEntryView
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// auditLogFetchLimit begrenzt, wie viele der neuesten Einträge aus der
|
||||
// DB geholt werden, bevor im Go-Code gefiltert/paginiert wird — echte
|
||||
// DB-seitige Offset-Paginierung wäre für ein potenziell sehr groß
|
||||
// werdendes Audit-Log der sauberere Ansatz, ist hier aber (noch) nicht
|
||||
// umgesetzt (siehe CLAUDE.md, Offene Punkte).
|
||||
const auditLogFetchLimit = 1000
|
||||
|
||||
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
||||
entries, err := s.store.ListAuditLog(r.Context(), auditLogFetchLimit)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.AuditEntry
|
||||
for _, e := range entries {
|
||||
if matchesQuery(q, e.Action, e.TargetType, e.Details) {
|
||||
gefiltert = append(gefiltert, e)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, e := range paginate(gefiltert, pag) {
|
||||
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||
|
||||
@@ -14,11 +14,57 @@ import (
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
|
||||
// werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
|
||||
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
|
||||
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
|
||||
type werkzeugFilter struct {
|
||||
Suche string
|
||||
Zweck string
|
||||
}
|
||||
|
||||
func istGueltigerVerarbeitungsort(v string) bool {
|
||||
for _, o := range gueltigeVerarbeitungsorte {
|
||||
if o == v {
|
||||
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
|
||||
return werkzeugFilter{
|
||||
Suche: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Zweck: r.URL.Query().Get("zweck"),
|
||||
}
|
||||
}
|
||||
|
||||
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
|
||||
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
|
||||
// nur exportierte Methoden per Reflection aufrufen kann.
|
||||
func (f werkzeugFilter) Aktiv() bool {
|
||||
return f.Suche != "" || f.Zweck != ""
|
||||
}
|
||||
|
||||
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
|
||||
if f.Zweck != "" {
|
||||
gefunden := false
|
||||
for _, z := range wz.GeeigneteZwecke {
|
||||
if z == f.Zweck {
|
||||
gefunden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !gefunden {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Suche != "" {
|
||||
q := strings.ToLower(f.Suche)
|
||||
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
|
||||
if !strings.Contains(heuhaufen, q) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
|
||||
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
|
||||
// nur für Verarbeitungsländer statt Zwecke.
|
||||
func istGueltigesLand(v string) bool {
|
||||
for _, l := range weltLaenderliste {
|
||||
if l == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -26,12 +72,17 @@ func istGueltigerVerarbeitungsort(v string) bool {
|
||||
}
|
||||
|
||||
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
|
||||
// — exakt die sechs Zweck-Kategorien aus dem Fragebogen/der geplanten
|
||||
// Zweckabgleich-Stufe (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.
|
||||
var gueltigeZwecke = []string{"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche"}
|
||||
// (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 {
|
||||
@@ -57,17 +108,19 @@ func splitListe(s string) []string {
|
||||
}
|
||||
|
||||
type werkzeugListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
TrainingStandard bool
|
||||
Aufbewahrung string
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
LetztePruefung string
|
||||
NichtAktuell bool
|
||||
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
|
||||
@@ -84,16 +137,41 @@ func formatAufbewahrung(tage *int) string {
|
||||
}
|
||||
|
||||
type werkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []werkzeugListItem
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []werkzeugListItem
|
||||
Filter werkzeugFilter
|
||||
ZweckeOptionen []string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
|
||||
// Katalogeintrag im Ergebnis als "nicht aktuell geprüft" markiert wird
|
||||
// (siehe CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
|
||||
// belegbar UND aktuell sein.
|
||||
const werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
|
||||
// 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) {
|
||||
@@ -102,15 +180,30 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
filter := werkzeugFilterFromRequest(r)
|
||||
var gefiltert []store.Werkzeug
|
||||
for _, wz := range werkzeuge {
|
||||
if filter.matches(wz) {
|
||||
gefiltert = append(gefiltert, wz)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
seite := paginate(gefiltert, pag)
|
||||
|
||||
data := werkzeugListeData{
|
||||
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||
}
|
||||
for _, wz := range seite {
|
||||
label, css := pruefStatus(wz.LetztePruefung)
|
||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||
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"),
|
||||
NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach,
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||
@@ -123,32 +216,33 @@ 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 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
|
||||
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
|
||||
}
|
||||
|
||||
// zweckeAusgewaehltVon baut die Checkbox-Vorbelegung aus einer bereits
|
||||
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
|
||||
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
||||
func zweckeAusgewaehltVon(ausgewaehlt []string) map[string]bool {
|
||||
func auswahlSet(ausgewaehlt []string) map[string]bool {
|
||||
m := make(map[string]bool, len(ausgewaehlt))
|
||||
for _, z := range ausgewaehlt {
|
||||
m[z] = true
|
||||
@@ -165,12 +259,13 @@ func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, act
|
||||
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
|
||||
}
|
||||
return werkzeugFormData{
|
||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
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: zweckeAusgewaehltVon(wz.GeeigneteZwecke),
|
||||
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
|
||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||
Quelle: wz.Quelle,
|
||||
}
|
||||
@@ -179,7 +274,8 @@ func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, act
|
||||
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{
|
||||
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
|
||||
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||
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)
|
||||
@@ -194,9 +290,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")
|
||||
@@ -221,21 +322,22 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
||||
}
|
||||
|
||||
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")),
|
||||
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||
GeeigneteZwecke: geeigneteZwecke,
|
||||
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,
|
||||
}, ""
|
||||
}
|
||||
|
||||
@@ -244,13 +346,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",
|
||||
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
|
||||
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
|
||||
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(r.Form["geeignete_zwecke"]),
|
||||
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
|
||||
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||
}
|
||||
|
||||
@@ -2,17 +2,19 @@ package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
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"},
|
||||
@@ -116,3 +118,71 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberWerkzeugListeFiltertNachSucheUndZweck(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
fs.werkzeuge["a"] = store.Werkzeug{
|
||||
ID: "a", Name: "ChatGPT Enterprise", Anbieter: "OpenAI",
|
||||
Verarbeitungslaender: []string{"USA"}, GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
LetztePruefung: time.Now(),
|
||||
}
|
||||
fs.werkzeuge["b"] = store.Werkzeug{
|
||||
ID: "b", Name: "DeepL Pro", Anbieter: "DeepL SE",
|
||||
Verarbeitungslaender: []string{"Deutschland"}, GeeigneteZwecke: []string{"Übersetzung"},
|
||||
LetztePruefung: time.Now(),
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=deepl")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.Code)
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || !strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||
t.Fatalf("q=deepl sollte nur DeepL Pro zeigen, body: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?zweck=Textgenerierung")
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||
t.Fatalf("zweck=Textgenerierung sollte nur ChatGPT Enterprise zeigen, body: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=nichts-passt")
|
||||
if !strings.Contains(resp.Body.String(), "Keine Treffer") {
|
||||
t.Fatalf("erwartet 'Keine Treffer' bei erfolgloser Suche, body: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberWerkzeugListePaginiert(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
for i := 0; i < 25; i++ {
|
||||
id := fmt.Sprintf("wz-%02d", i)
|
||||
fs.werkzeuge[id] = store.Werkzeug{
|
||||
ID: id, Name: fmt.Sprintf("Werkzeug %02d", i), Anbieter: "Testanbieter",
|
||||
LetztePruefung: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.Code)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Seite 1 von 2") {
|
||||
t.Fatalf("erwartet 'Seite 1 von 2' bei 25 Einträgen (20 pro Seite), body: %s", resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Weiter") || strings.Contains(resp.Body.String(), "Zurück") {
|
||||
t.Fatalf("Seite 1 sollte einen Weiter-, aber keinen Zurück-Button zeigen")
|
||||
}
|
||||
|
||||
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?page=2")
|
||||
if !strings.Contains(resp.Body.String(), "Seite 2 von 2") {
|
||||
t.Fatalf("erwartet 'Seite 2 von 2', body: %s", resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Weiter") || !strings.Contains(resp.Body.String(), "Zurück") {
|
||||
t.Fatalf("Seite 2 sollte einen Zurück-, aber keinen Weiter-Button zeigen")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
@@ -20,9 +22,11 @@ type fallListItem struct {
|
||||
}
|
||||
|
||||
type fallListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Faelle []fallListItem
|
||||
Title string
|
||||
Nav navData
|
||||
Faelle []fallListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
||||
@@ -34,16 +38,25 @@ func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := fallListData{Title: "Posteingang", Nav: navFor(r)}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var offene []fallListItem
|
||||
for _, a := range antraege {
|
||||
if a.Status != "eingereicht" {
|
||||
continue
|
||||
}
|
||||
data.Faelle = append(data.Faelle, fallListItem{
|
||||
if !matchesQuery(q, a.Titel, a.Haeufigkeit) {
|
||||
continue
|
||||
}
|
||||
offene = append(offene, fallListItem{
|
||||
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
pag := paginationFromRequest(r, len(offene))
|
||||
data := fallListData{
|
||||
Title: "Posteingang", Nav: navFor(r),
|
||||
Faelle: paginate(offene, pag), Suche: q, Pagination: pag,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -77,9 +90,18 @@ type fallDetailData struct {
|
||||
Entscheidung *entscheidungView
|
||||
CanEntscheiden bool
|
||||
WerkzeugOptionen []werkzeugOption
|
||||
Freigabeschritte []freigabeschrittView
|
||||
Error string
|
||||
}
|
||||
|
||||
// freigabeschrittView zeigt den Stand eines einzelnen, zusätzlich zur
|
||||
// Fachebene-Entscheidung nötigen Freigabeschritts an.
|
||||
type freigabeschrittView struct {
|
||||
RolleName string
|
||||
Status string
|
||||
Kommentar 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.
|
||||
@@ -146,6 +168,24 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
||||
data.Entscheidung = v
|
||||
}
|
||||
|
||||
// Nicht auf Status "wartet_auf_freigabe" beschränkt: die Freigabehistorie
|
||||
// bleibt auch nach vollständiger Entscheidung sichtbar (Grundregel: eine
|
||||
// Entscheidung wird nie ohne vollständige Herleitung angezeigt). Für
|
||||
// Anträge ohne ausgelöste Freigabe-Regel liefert die Abfrage einfach
|
||||
// eine leere Liste, das Template blendet den Abschnitt dann aus.
|
||||
schritte, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
|
||||
if err == nil {
|
||||
for _, schritt := range schritte {
|
||||
rolleName := ""
|
||||
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
|
||||
rolleName = rolle.Name
|
||||
}
|
||||
data.Freigabeschritte = append(data.Freigabeschritte, freigabeschrittView{
|
||||
RolleName: rolleName, Status: schritt.Status, Kommentar: schritt.Kommentar,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -212,7 +252,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||
}
|
||||
var werkzeugName string
|
||||
if istGenehmigung {
|
||||
zulaessig := false
|
||||
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||
@@ -230,7 +269,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
werkzeugName = werkzeug.Name
|
||||
in.WerkzeugID = &werkzeugID
|
||||
in.WerkzeugSnapshot = &werkzeug
|
||||
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
||||
@@ -242,6 +280,39 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Konfigurierbarer Mehrfach-Freigabe-Workflow (siehe CLAUDE.md): eine
|
||||
// Genehmigung ist nicht zwangsläufig sofort final — löst die
|
||||
// Bewertung eine Freigabe-Regel des Mandanten aus (z. B.
|
||||
// dsfa_erforderlich → Datenschutzbeauftragter), wartet der Antrag
|
||||
// erst auf diese zusätzlichen Freigaben, bevor SetAntragStatus auf
|
||||
// "entschieden" geht und ein Registereintrag entsteht.
|
||||
if istGenehmigung {
|
||||
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), user.AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rollenIDs := triggeredGenehmigerRollen(regeln, bewertung); len(rollenIDs) > 0 {
|
||||
for _, rolleID := range rollenIDs {
|
||||
if _, err := s.store.CreateFreigabeschritt(r.Context(), antrag.ID, rolleID); err != nil {
|
||||
http.Error(w, "Freigabeschritt konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "wartet_auf_freigabe"); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_wartet_auf_freigabe", "antrag", antrag.ID, entscheidungTyp); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -250,27 +321,8 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
|
||||
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
|
||||
// Genehmigtes, das im Register stehen müsste.
|
||||
if istGenehmigung {
|
||||
abteilungName := ""
|
||||
if antrag.AbteilungID != nil {
|
||||
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
|
||||
abteilungName = abt.Name
|
||||
}
|
||||
}
|
||||
var auflagen []string
|
||||
for _, a := range bewertung.Anforderungen {
|
||||
auflagen = append(auflagen, a.Beschreibung)
|
||||
}
|
||||
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
|
||||
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
||||
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
||||
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
||||
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
||||
}); err != nil {
|
||||
if err := s.registriereGenehmigung(r.Context(), antrag, entscheidung, bewertung); err != nil {
|
||||
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -278,3 +330,67 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// registriereGenehmigung schließt eine (ggf. erst nach zusätzlichen
|
||||
// Freigaben) endgültig genehmigte Entscheidung ab: Registereintrag
|
||||
// anlegen (siehe CLAUDE.md — Antragstatus ist an dieser Stelle bereits
|
||||
// auf "entschieden" gesetzt). Wiederverwendet von handleFallEntscheiden
|
||||
// (keine zusätzliche Freigabe nötig) und handleFreigabeEntscheiden
|
||||
// (letzter ausstehender Freigabeschritt wurde soeben genehmigt).
|
||||
func (s *Server) registriereGenehmigung(ctx context.Context, antrag store.Antrag, entscheidung store.Entscheidung, bewertung store.Bewertung) error {
|
||||
abteilungName := ""
|
||||
if antrag.AbteilungID != nil {
|
||||
if abt, err := s.store.GetAbteilung(ctx, *antrag.AbteilungID); err == nil {
|
||||
abteilungName = abt.Name
|
||||
}
|
||||
}
|
||||
werkzeugName := ""
|
||||
if entscheidung.WerkzeugSnapshot != nil {
|
||||
werkzeugName = entscheidung.WerkzeugSnapshot.Name
|
||||
}
|
||||
verantwortlicherEmail := ""
|
||||
if u, err := s.store.GetUser(ctx, entscheidung.EntscheiderUserID); err == nil {
|
||||
verantwortlicherEmail = u.Email
|
||||
}
|
||||
var auflagen []string
|
||||
for _, a := range bewertung.Anforderungen {
|
||||
auflagen = append(auflagen, a.Beschreibung)
|
||||
}
|
||||
_, err := s.store.CreateRegistereintrag(ctx, store.RegistereintragInput{
|
||||
AccountID: antrag.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
||||
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
||||
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
||||
Verantwortlicher: verantwortlicherEmail, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// triggeredGenehmigerRollen liefert die eindeutigen Genehmiger-Rollen,
|
||||
// deren Freigabe-Regel durch diese Bewertung ausgelöst wird — Bedingung
|
||||
// ist eine der vom Regelwerk bereits abgeleiteten Größen (Anforderungs-
|
||||
// ID, Einstufung, Datenklasse), kein freier Regel-Editor.
|
||||
func triggeredGenehmigerRollen(regeln []store.FreigabeRegel, bewertung store.Bewertung) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, regel := range regeln {
|
||||
var treffer bool
|
||||
switch regel.BedingungTyp {
|
||||
case "anforderung":
|
||||
for _, a := range bewertung.Anforderungen {
|
||||
if a.ID == regel.BedingungWert {
|
||||
treffer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
case "einstufung":
|
||||
treffer = bewertung.Einstufung == regel.BedingungWert
|
||||
case "datenklasse":
|
||||
treffer = bewertung.Datenklasse == regel.BedingungWert
|
||||
}
|
||||
if treffer && !seen[regel.GenehmigerRolleID] {
|
||||
seen[regel.GenehmigerRolleID] = true
|
||||
out = append(out, regel.GenehmigerRolleID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
493
internal/web/freigabe_handlers.go
Normal file
493
internal/web/freigabe_handlers.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||
// und CLAUDE.md. Drei Seitengruppen:
|
||||
// - Admin (Ebene 4): Genehmiger-Rollen anlegen/Mitglieder zuweisen,
|
||||
// Freigabe-Regeln definieren ("wenn Bedingung X, dann Freigabe
|
||||
// durch Rolle Y nötig").
|
||||
// - Jeder angemeldete Nutzer: "Meine Freigaben" — eigene ausstehende
|
||||
// Freigabeschritte, unabhängig von app_user.role (eine
|
||||
// Genehmiger-Rolle ist orthogonal zur Zugriffsrolle).
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// standardGenehmigerRollen wird bei jeder neuen Firma automatisch angelegt
|
||||
// (ohne Mitglieder/Freigabe-Regeln) — der Admin muss nur noch Personen
|
||||
// zuordnen und bei Bedarf Freigabe-Regeln ergänzen, statt bei null
|
||||
// anzufangen. Die Beschreibung ist reine Orientierungshilfe im UI; welche
|
||||
// Bedingung welche Rolle tatsächlich auslöst, entscheidet weiterhin jede
|
||||
// Firma selbst über /verwaltung/freigabe-regeln — das System schreibt
|
||||
// keine fachliche Bindung vor.
|
||||
var standardGenehmigerRollen = []struct {
|
||||
Name string
|
||||
Beschreibung string
|
||||
}{
|
||||
{"Datenschutzbeauftragter", "Prüft Anträge mit besonderen Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung erforderlich ist."},
|
||||
{"Geschäftsführer", "Trifft die unternehmerische Entscheidung bei besonders risikobehafteten Vorhaben."},
|
||||
{"KI-Manager", "Bewertet den Antrag fachlich."},
|
||||
{"CISO", "Prüft den Antrag aus Sicherheitssicht (Auditor)."},
|
||||
}
|
||||
|
||||
// seedStandardGenehmigerRollen wird direkt nach dem Anlegen einer neuen
|
||||
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage).
|
||||
func (s *Server) seedStandardGenehmigerRollen(ctx context.Context, accountID string) error {
|
||||
for _, r := range standardGenehmigerRollen {
|
||||
if _, err := s.store.CreateGenehmigerRolle(ctx, accountID, r.Name, r.Beschreibung); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Admin: Genehmiger-Rollen ───────────────────────────────────────
|
||||
|
||||
type genehmigerRolleItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Beschreibung string
|
||||
Mitglieder []nutzerOption
|
||||
}
|
||||
|
||||
type nutzerOption struct {
|
||||
ID string
|
||||
Email string
|
||||
}
|
||||
|
||||
type genehmigerRollenListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Rollen []genehmigerRolleItem
|
||||
Nutzer []nutzerOption
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleGenehmigerRollenListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListUsersForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := genehmigerRollenListeData{Title: "Genehmiger-Rollen", Nav: navFor(r)}
|
||||
for _, u := range users {
|
||||
data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email})
|
||||
}
|
||||
for _, g := range rollen {
|
||||
item := genehmigerRolleItem{ID: g.ID, Name: g.Name, Beschreibung: g.Beschreibung}
|
||||
mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID)
|
||||
if err == nil {
|
||||
for _, m := range mitglieder {
|
||||
item.Mitglieder = append(item.Mitglieder, nutzerOption{ID: m.UserID, Email: m.Email})
|
||||
}
|
||||
}
|
||||
data.Rollen = append(data.Rollen, item)
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "genehmiger-rollen-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGenehmigerRolleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
beschreibung := strings.TrimSpace(r.FormValue("beschreibung"))
|
||||
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name, beschreibung); err != nil {
|
||||
http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleGenehmigerRolleDelete entfernt eine Genehmiger-Rolle. Solange
|
||||
// noch Mitglieder zugewiesen sind oder eine Freigabe-Regel darauf
|
||||
// verweist, schlägt das per Fremdschlüssel fehl — bewusst kein CASCADE,
|
||||
// ein stillschweigendes Mit-Löschen wirksamer Freigabe-Regeln wäre
|
||||
// überraschend und sicherheitsrelevant.
|
||||
func (s *Server) handleGenehmigerRolleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
rolle, err := s.store.GetGenehmigerRolle(r.Context(), id)
|
||||
if err != nil || rolle.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteGenehmigerRolle(r.Context(), id); err != nil {
|
||||
http.Error(w, "Genehmiger-Rolle wird noch verwendet (Mitglieder oder Freigabe-Regeln) und kann nicht gelöscht werden", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleGenehmigerRolleMitgliedHinzufuegen(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
rolleID := r.PathValue("id")
|
||||
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||
if err != nil || rolle.AccountID != accountID {
|
||||
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userID := r.FormValue("user_id")
|
||||
target, err := s.store.GetUser(r.Context(), userID)
|
||||
if err != nil || target.AccountID != accountID {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.AddNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
|
||||
http.Error(w, "Zuweisung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleGenehmigerRolleMitgliedEntfernen(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
rolleID := r.PathValue("id")
|
||||
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||
if err != nil || rolle.AccountID != accountID {
|
||||
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
userID := r.PathValue("userID")
|
||||
if err := s.store.RemoveNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
|
||||
http.Error(w, "Entfernen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ─── Admin: Freigabe-Regeln ─────────────────────────────────────────
|
||||
|
||||
// bedingungOption ist ein Auswahlpunkt im Freigabe-Regel-Formular —
|
||||
// Value kodiert "typ:wert" (z. B. "anforderung:dsfa_erforderlich"),
|
||||
// damit ein einziges <select> ohne JavaScript reicht statt zweier
|
||||
// voneinander abhängiger Dropdowns.
|
||||
type bedingungOption struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
type bedingungGruppe struct {
|
||||
Label string
|
||||
Optionen []bedingungOption
|
||||
}
|
||||
|
||||
type freigabeRegelItem struct {
|
||||
ID string
|
||||
BedingungLabel string
|
||||
GenehmigerRolle string
|
||||
}
|
||||
|
||||
type freigabeRegelnListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Regeln []freigabeRegelItem
|
||||
BedingungGruppen []bedingungGruppe
|
||||
RollenOptionen []store.GenehmigerRolle
|
||||
Error string
|
||||
}
|
||||
|
||||
// bedingungGruppenAusRegelwerk baut die Freigabe-Regel-Bedingungsauswahl
|
||||
// aus dem tatsächlich geladenen Regelwerk (nicht frei erfunden) — jede
|
||||
// Anforderungs-/Einstufungs-/Datenklasse-ID, die das System ohnehin
|
||||
// kennt, ist eine mögliche Bedingung.
|
||||
func (s *Server) bedingungGruppenAusRegelwerk() []bedingungGruppe {
|
||||
var anforderungen []bedingungOption
|
||||
for _, a := range s.regelwerk.Anforderungen.Anforderungen {
|
||||
label := a.ID
|
||||
if a.Beschreibung != "" {
|
||||
label = a.Beschreibung
|
||||
}
|
||||
anforderungen = append(anforderungen, bedingungOption{Value: "anforderung:" + a.ID, Label: label})
|
||||
}
|
||||
var einstufungen []bedingungOption
|
||||
for _, e := range s.regelwerk.Einstufung.Stufen {
|
||||
einstufungen = append(einstufungen, bedingungOption{Value: "einstufung:" + e.ID, Label: e.ID})
|
||||
}
|
||||
var datenklassen []bedingungOption
|
||||
for _, d := range s.regelwerk.Datenklasse.Stufen {
|
||||
datenklassen = append(datenklassen, bedingungOption{Value: "datenklasse:" + d.ID, Label: d.ID})
|
||||
}
|
||||
return []bedingungGruppe{
|
||||
{Label: "Anforderung", Optionen: anforderungen},
|
||||
{Label: "Einstufung", Optionen: einstufungen},
|
||||
{Label: "Datenklasse", Optionen: datenklassen},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleFreigabeRegelnListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rollenName := map[string]string{}
|
||||
for _, g := range rollen {
|
||||
rollenName[g.ID] = g.Name
|
||||
}
|
||||
data := freigabeRegelnListeData{
|
||||
Title: "Freigabe-Regeln", Nav: navFor(r),
|
||||
BedingungGruppen: s.bedingungGruppenAusRegelwerk(), RollenOptionen: rollen,
|
||||
}
|
||||
for _, regel := range regeln {
|
||||
data.Regeln = append(data.Regeln, freigabeRegelItem{
|
||||
ID: regel.ID,
|
||||
BedingungLabel: bedingungTypLabel(regel.BedingungTyp) + ": " + regel.BedingungWert,
|
||||
GenehmigerRolle: rollenName[regel.GenehmigerRolleID],
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "freigabe-regeln-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func bedingungTypLabel(typ string) string {
|
||||
switch typ {
|
||||
case "anforderung":
|
||||
return "Anforderung"
|
||||
case "einstufung":
|
||||
return "Einstufung"
|
||||
case "datenklasse":
|
||||
return "Datenklasse"
|
||||
default:
|
||||
return typ
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleFreigabeRegelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
teile := strings.SplitN(r.FormValue("bedingung"), ":", 2)
|
||||
rolleID := r.FormValue("genehmiger_rolle_id")
|
||||
if len(teile) != 2 || teile[0] == "" || teile[1] == "" || rolleID == "" {
|
||||
http.Error(w, "Bedingung und Genehmiger-Rolle sind Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
typ, wert := teile[0], teile[1]
|
||||
if typ != "anforderung" && typ != "einstufung" && typ != "datenklasse" {
|
||||
http.Error(w, "ungültiger Bedingungstyp", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||
if err != nil || rolle.AccountID != accountID {
|
||||
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateFreigabeRegel(r.Context(), accountID, typ, wert, rolleID); err != nil {
|
||||
http.Error(w, "Freigabe-Regel konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleFreigabeRegelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
regel, err := s.store.GetFreigabeRegel(r.Context(), id)
|
||||
if err != nil || regel.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Freigabe-Regel nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteFreigabeRegel(r.Context(), id); err != nil {
|
||||
http.Error(w, "Freigabe-Regel konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ─── Meine Freigaben (jeder angemeldete Nutzer) ─────────────────────
|
||||
|
||||
type freigabeItem struct {
|
||||
ID string
|
||||
AntragID string
|
||||
AntragTitel string
|
||||
RolleName string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type freigabenListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Freigaben []freigabeItem
|
||||
}
|
||||
|
||||
// handleFreigabenListe zeigt die eigenen ausstehenden Freigabeschritte
|
||||
// — unabhängig von app_user.role, denn eine Genehmiger-Rolle ist eine
|
||||
// orthogonale Freigabe-Funktion, keine Zugriffsrolle.
|
||||
func (s *Server) handleFreigabenListe(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
schritte, err := s.store.ListAusstehendeFreigabeschritteForUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Freigaben konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := freigabenListeData{Title: "Meine Freigaben", Nav: navFor(r)}
|
||||
for _, schritt := range schritte {
|
||||
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rolleName := ""
|
||||
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
|
||||
rolleName = rolle.Name
|
||||
}
|
||||
data.Freigaben = append(data.Freigaben, freigabeItem{
|
||||
ID: schritt.ID, AntragID: antrag.ID, AntragTitel: antrag.Titel,
|
||||
RolleName: rolleName, CreatedAt: schritt.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "freigaben-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFreigabeEntscheiden trägt die Entscheidung einer Person zu
|
||||
// einem einzelnen Freigabeschritt ein. Genehmigt sie den letzten noch
|
||||
// ausstehenden Schritt eines Antrags, wird die ursprüngliche Genehmigung
|
||||
// jetzt final abgeschlossen (Registereintrag). Lehnt sie ab, kippt das
|
||||
// den gesamten Antrag — alle anderen offenen Schritte desselben Antrags
|
||||
// werden automatisch mit abgelehnt (KaskadiereAblehnung), und eine neue
|
||||
// Entscheidung "abgelehnt" übersteuert die ursprüngliche Genehmigung
|
||||
// (entscheidung ist append-only, siehe CLAUDE.md).
|
||||
func (s *Server) handleFreigabeEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := r.PathValue("id")
|
||||
|
||||
schritt, err := s.store.GetFreigabeschritt(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rollen, err := s.store.ListGenehmigerRollenForUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hatRolle := false
|
||||
for _, rl := range rollen {
|
||||
if rl.ID == schritt.GenehmigerRolleID {
|
||||
hatRolle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hatRolle {
|
||||
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if schritt.Status != "ausstehend" {
|
||||
http.Error(w, "dieser Freigabeschritt wurde bereits entschieden", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
entscheidungTyp := r.FormValue("entscheidung")
|
||||
kommentar := r.FormValue("kommentar")
|
||||
if entscheidungTyp != "genehmigt" && entscheidungTyp != "abgelehnt" {
|
||||
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.store.EntscheideFreigabeschritt(r.Context(), id, entscheidungTyp, user.ID, kommentar); err != nil {
|
||||
http.Error(w, "Freigabeschritt konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
|
||||
if err != nil {
|
||||
http.Error(w, "Antrag nicht gefunden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rolle, _ := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID)
|
||||
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "freigabeschritt_entschieden", "antrag", antrag.ID, rolle.Name+": "+entscheidungTyp); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Bewertung nicht gefunden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if entscheidungTyp == "abgelehnt" {
|
||||
if err := s.store.KaskadiereAblehnung(r.Context(), antrag.ID, id); err != nil {
|
||||
http.Error(w, "Kaskadierung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
begruendung := "Freigabe durch " + rolle.Name + " wurde verweigert."
|
||||
if kommentar != "" {
|
||||
begruendung += " Kommentar: " + kommentar
|
||||
}
|
||||
if _, err := s.store.CreateEntscheidung(r.Context(), store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||
Entscheidung: "abgelehnt", Begruendung: begruendung,
|
||||
}); err != nil {
|
||||
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
alle, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Freigabeschritte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
vollstaendig := true
|
||||
for _, fs := range alle {
|
||||
if fs.Status != "genehmigt" {
|
||||
vollstaendig = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if vollstaendig {
|
||||
letzteEntscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Entscheidung nicht gefunden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.registriereGenehmigung(r.Context(), antrag, letzteEntscheidung, bewertung); err != nil {
|
||||
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/freigaben", http.StatusSeeOther)
|
||||
}
|
||||
283
internal/web/freigabe_handlers_test.go
Normal file
283
internal/web/freigabe_handlers_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// besondereKategorieAntragForm loest ueber b2=ja die Datenklasse
|
||||
// besondere_kategorie aus, die laut rules/anforderungen.yaml die
|
||||
// Anforderung dsfa_erforderlich nach sich zieht — Grundlage fuer die
|
||||
// Freigabe-Workflow-Tests.
|
||||
func besondereKategorieAntragForm() url.Values {
|
||||
return url.Values{
|
||||
"titel": {"Gesundheitsdaten auswerten"}, "beschreibung": {"KI wertet Gesundheitsdaten aus"},
|
||||
"ergebnis": {"Auswertung"}, "haeufigkeit": {"gelegentlich"},
|
||||
"b1": {"nein"}, "b2": {"ja"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
|
||||
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
|
||||
"c4": {"nein"}, "c5": {"ja"},
|
||||
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
|
||||
}
|
||||
}
|
||||
|
||||
// seedFreigabeSzenario legt einen Mandanten mit Mitarbeiter (reicht
|
||||
// einen Antrag ein, der dsfa_erforderlich ausloest), Verantwortlichem
|
||||
// und einem zentralen Werkzeug an — die Grundlage, auf der die
|
||||
// einzelnen Freigabe-Workflow-Tests aufbauen.
|
||||
func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc, err := fs.CreateAccount(ctx, "Freigabe-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter-fg@example.com", "mitarbeiter")
|
||||
|
||||
werkzeugForm := url.Values{
|
||||
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||
"training_standard": {"on"}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||
}
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber FG", "betreiber-fg@example.com", "betreiber")
|
||||
if resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm); resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Werkzeug anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp := postForm(t, s, mitarbeiterCookie, "/antraege", besondereKategorieAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
for id, a := range fs.antraege {
|
||||
if a.AccountID == acc.ID {
|
||||
antragID = id
|
||||
}
|
||||
}
|
||||
if antragID == "" {
|
||||
t.Fatal("kein Antrag im Mandanten gefunden")
|
||||
}
|
||||
|
||||
verantwortlicherCookie = seedUserInAccount(t, fs, acc.ID, "verantwortlicher-fg@example.com", "verantwortlicher")
|
||||
return acc.ID, antragID, verantwortlicherCookie
|
||||
}
|
||||
|
||||
// werkzeugIDFor findet die ID des einzigen im fakeStore angelegten
|
||||
// Werkzeugs — fuer die Genehmigungs-Formulare in diesen Tests reicht ein
|
||||
// einziges zentrales Werkzeug.
|
||||
func werkzeugIDFor(fs *fakeStore, name string) string {
|
||||
for id, w := range fs.werkzeuge {
|
||||
if w.Name == name {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestOhneFreigabeRegelnVerhaeltSichWieVorher(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if fs.antraege[antragID].Status != "entschieden" {
|
||||
t.Fatalf("Status = %q, want entschieden (keine Freigabe-Regel konfiguriert)", fs.antraege[antragID].Status)
|
||||
}
|
||||
regEintraege := fs.registereintraege[fs.antraege[antragID].AccountID]
|
||||
if len(regEintraege) != 1 {
|
||||
t.Fatalf("erwartet 1 Registereintrag ohne Freigabe-Regeln, got %d", len(regEintraege))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
if _, err := fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID); err != nil {
|
||||
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||
}
|
||||
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb@example.com", "mitarbeiter")
|
||||
dsbUser, err := fs.GetUserByEmail(context.Background(), "dsb@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if err := fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := fs.antraege[antragID].Status; got != "wartet_auf_freigabe" {
|
||||
t.Fatalf("Status = %q, want wartet_auf_freigabe", got)
|
||||
}
|
||||
if len(fs.registereintraege[accountID]) != 0 {
|
||||
t.Fatalf("erwartet noch keinen Registereintrag vor vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
|
||||
}
|
||||
|
||||
listResp := getWithCookie(t, s, dsbCookie, "/freigaben")
|
||||
if listResp.Code != http.StatusOK || !strings.Contains(listResp.Body.String(), "Gesundheitsdaten auswerten") {
|
||||
t.Fatalf("Meine Freigaben zeigt den Antrag nicht, status=%d body=%s", listResp.Code, listResp.Body.String())
|
||||
}
|
||||
|
||||
var schrittID string
|
||||
for id, fsr := range fs.freigabeschritte {
|
||||
if fsr.AntragID == antragID {
|
||||
schrittID = id
|
||||
}
|
||||
}
|
||||
if schrittID == "" {
|
||||
t.Fatal("kein Freigabeschritt fuer den Antrag gefunden")
|
||||
}
|
||||
|
||||
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||
if decideResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("freigabe entscheiden: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
|
||||
}
|
||||
if got := fs.antraege[antragID].Status; got != "entschieden" {
|
||||
t.Fatalf("Status nach vollstaendiger Freigabe = %q, want entschieden", got)
|
||||
}
|
||||
if len(fs.registereintraege[accountID]) != 1 {
|
||||
t.Fatalf("erwartet 1 Registereintrag nach vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung", "")
|
||||
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||
fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID)
|
||||
|
||||
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb2@example.com", "mitarbeiter")
|
||||
dsbUser, _ := fs.GetUserByEmail(context.Background(), "dsb2@example.com")
|
||||
fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID)
|
||||
seedUserInAccount(t, fs, accountID, "gf@example.com", "mitarbeiter")
|
||||
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var dsbSchrittID string
|
||||
for id, fsr := range fs.freigabeschritte {
|
||||
if fsr.AntragID == antragID && fsr.GenehmigerRolleID == dsb.ID {
|
||||
dsbSchrittID = id
|
||||
}
|
||||
}
|
||||
if dsbSchrittID == "" {
|
||||
t.Fatal("kein DSB-Freigabeschritt gefunden")
|
||||
}
|
||||
|
||||
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+dsbSchrittID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"abgelehnt"}, "kommentar": {"Nicht ausreichend pseudonymisiert"},
|
||||
})
|
||||
if decideResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("freigabe ablehnen: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
|
||||
}
|
||||
|
||||
if got := fs.antraege[antragID].Status; got != "entschieden" {
|
||||
t.Fatalf("Status nach Ablehnung = %q, want entschieden", got)
|
||||
}
|
||||
letzte := fs.entscheidungen[antragID]
|
||||
if len(letzte) == 0 || letzte[len(letzte)-1].Entscheidung != "abgelehnt" {
|
||||
t.Fatalf("letzte Entscheidung = %+v, want abgelehnt", letzte)
|
||||
}
|
||||
for _, fsr := range fs.freigabeschritte {
|
||||
if fsr.AntragID == antragID && fsr.Status == "ausstehend" {
|
||||
t.Fatalf("erwartet keine ausstehenden Freigabeschritte mehr nach Ablehnung, gefunden: %+v", fsr)
|
||||
}
|
||||
}
|
||||
if len(fs.registereintraege[accountID]) != 0 {
|
||||
t.Fatalf("erwartet keinen Registereintrag nach Ablehnung, got %d", len(fs.registereintraege[accountID]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||
|
||||
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")
|
||||
|
||||
postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||
})
|
||||
var schrittID string
|
||||
for id, fsr := range fs.freigabeschritte {
|
||||
if fsr.AntragID == antragID {
|
||||
schrittID = id
|
||||
}
|
||||
}
|
||||
|
||||
resp := postForm(t, s, fremdCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 fuer eine Person ohne die noetige Genehmiger-Rolle", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenehmigerRolleCRUD(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "GR-Mandant", "admin-gr@example.com", "admin")
|
||||
admin, err := fs.GetUserByEmail(context.Background(), "admin-gr@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen", url.Values{"name": {"Geschaeftsfuehrung"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var rolleID string
|
||||
for id, g := range fs.genehmigerRollen {
|
||||
if g.Name == "Geschaeftsfuehrung" {
|
||||
rolleID = id
|
||||
}
|
||||
}
|
||||
if rolleID == "" {
|
||||
t.Fatal("Genehmiger-Rolle wurde nicht angelegt")
|
||||
}
|
||||
|
||||
assignResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder", url.Values{"user_id": {admin.ID}})
|
||||
if assignResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("zuweisen: status = %d, body: %s", assignResp.Code, assignResp.Body.String())
|
||||
}
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/genehmiger-rollen")
|
||||
if !strings.Contains(list.Body.String(), "admin-gr@example.com") {
|
||||
t.Errorf("erwartet das zugewiesene Mitglied auf der Liste, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
removeResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder/"+admin.ID+"/entfernen", url.Values{})
|
||||
if removeResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("entfernen: status = %d, body: %s", removeResp.Code, removeResp.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
||||
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())
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, `href="/antraege"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Meine offenen Anträge") {
|
||||
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +29,9 @@ func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
|
||||
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())
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, `href="/faelle"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Posteingang") {
|
||||
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
|
||||
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`) {
|
||||
if !strings.Contains(resp.Body.String(), `href="/betreiber"`) || !strings.Contains(resp.Body.String(), "Zur Plattform") {
|
||||
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||
|
||||
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",
|
||||
}
|
||||
98
internal/web/loeschfrist_handlers.go
Normal file
98
internal/web/loeschfrist_handlers.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Löschfristen je Datenklasse (Migration 0020) — schließt rules/OPEN.md,
|
||||
// Punkt 4. Die Tageswerte sind KEINE gesetzliche Vorgabe (die DSGVO
|
||||
// nennt keine festen Fristen, nur den Grundsatz "so lange wie für den
|
||||
// Zweck nötig", Art. 5 Abs. 1 lit. e) — deshalb pro Mandant einstellbar,
|
||||
// vom Datenschutzbeauftragten hinterlegt, mit risikogestaffelten
|
||||
// Vorschlagswerten vorbelegt. Wie alle anderen /verwaltung/*-Seiten
|
||||
// bewusst admin-only (konsistent mit Nutzer-/Abteilungs-/Genehmiger-
|
||||
// Rollen-Verwaltung) statt an den Namen einer Genehmiger-Rolle
|
||||
// gekoppelt — Genehmiger-Rollen sind laut Architektur explizit ohne
|
||||
// eigenes Zugriffsrecht (siehe CLAUDE.md, Freigabe-Workflow).
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// standardLoeschfristen sind risikogestaffelte Vorschlagswerte, mit
|
||||
// denen jede neue Firma vorbelegt wird — je sensibler die Datenklasse,
|
||||
// desto kürzer die vorgeschlagene Frist. Frei editierbar unter
|
||||
// /verwaltung/loeschfristen, keine Rechtsvorgabe.
|
||||
var standardLoeschfristen = []struct {
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
}{
|
||||
{"oeffentlich", 365},
|
||||
{"intern", 180},
|
||||
{"auftragsdaten", 90},
|
||||
{"personenbezogen", 90},
|
||||
{"berufsgeheimnis", 30},
|
||||
{"besondere_kategorie", 30},
|
||||
}
|
||||
|
||||
// seedStandardLoeschfristen wird direkt nach dem Anlegen einer neuen
|
||||
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage),
|
||||
// analog zu seedStandardGenehmigerRollen.
|
||||
func (s *Server) seedStandardLoeschfristen(ctx context.Context, accountID string) error {
|
||||
for _, l := range standardLoeschfristen {
|
||||
if _, err := s.store.UpsertLoeschfristEinstellung(ctx, accountID, l.DatenklasseID, l.MaxTage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type loeschfristZeile struct {
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
}
|
||||
|
||||
type loeschfristenListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Zeilen []loeschfristZeile
|
||||
Error string
|
||||
Gespeichert bool
|
||||
}
|
||||
|
||||
func (s *Server) handleLoeschfristenListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
einstellungen, err := s.store.ListLoeschfristEinstellungenForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Löschfristen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := loeschfristenListeData{Title: "Löschfristen", Nav: navFor(r), Gespeichert: r.URL.Query().Get("gespeichert") == "1"}
|
||||
for _, e := range einstellungen {
|
||||
data.Zeilen = append(data.Zeilen, loeschfristZeile{DatenklasseID: e.DatenklasseID, MaxTage: e.MaxTage})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "loeschfristen-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLoeschfristenSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
for _, l := range standardLoeschfristen {
|
||||
raw := r.FormValue("max_tage_" + l.DatenklasseID)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
maxTage, err := strconv.Atoi(raw)
|
||||
if err != nil || maxTage < 0 {
|
||||
http.Error(w, "ungültiger Wert für "+l.DatenklasseID, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpsertLoeschfristEinstellung(r.Context(), accountID, l.DatenklasseID, maxTage); err != nil {
|
||||
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/loeschfristen?gespeichert=1", http.StatusSeeOther)
|
||||
}
|
||||
@@ -11,35 +11,40 @@ package web
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
type mandantWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
TrainingStandard bool
|
||||
Aufbewahrung string
|
||||
GeeigneteZwecke string
|
||||
LetztePruefung string
|
||||
NichtAktuell bool
|
||||
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
|
||||
Verarbeitungsort string
|
||||
LetztePruefung string
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungslaender string
|
||||
LetztePruefung string
|
||||
}
|
||||
|
||||
type mandantWerkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []mandantWerkzeugItem
|
||||
Filter werkzeugFilter
|
||||
ZweckeOptionen []string
|
||||
Pagination pagination
|
||||
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||
}
|
||||
|
||||
@@ -69,16 +74,30 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
gesperrt[sp.WerkzeugID] = true
|
||||
}
|
||||
|
||||
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
filter := werkzeugFilterFromRequest(r)
|
||||
var gefiltert []store.Werkzeug
|
||||
for _, wz := range zentral {
|
||||
if filter.matches(wz) {
|
||||
gefiltert = append(gefiltert, wz)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
seite := paginate(gefiltert, pag)
|
||||
|
||||
data := mandantWerkzeugListeData{
|
||||
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||
}
|
||||
for _, wz := range seite {
|
||||
label, css := pruefStatus(wz.LetztePruefung)
|
||||
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach,
|
||||
Gesperrt: gesperrt[wz.ID],
|
||||
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 {
|
||||
@@ -86,7 +105,7 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
continue // zentral, oben schon gelistet
|
||||
}
|
||||
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
})
|
||||
}
|
||||
@@ -98,7 +117,8 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{
|
||||
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
|
||||
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||
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)
|
||||
|
||||
95
internal/web/pagination.go
Normal file
95
internal/web/pagination.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// pagination.go — wiederverwendbare serverseitige Paginierung für
|
||||
// Listen-Seiten (Werkzeugkatalog, Register, ...). Bewusst ohne
|
||||
// JavaScript: Seiten-/Filterwechsel läuft über ein einziges GET-Formular,
|
||||
// das Query-Parameter (q, zweck, page, ...) setzt — siehe CLAUDE.md,
|
||||
// Frontend.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultPerPage = 20
|
||||
|
||||
// pagination beschreibt die aktuelle Seite einer Liste. Templates lesen
|
||||
// daraus Seitenzahl, Gesamtzahl und ob Vor/Zurück-Buttons sichtbar sein
|
||||
// sollen.
|
||||
type pagination struct {
|
||||
Page int
|
||||
PerPage int
|
||||
Total int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// paginationFromRequest liest "page" aus der Query (Standard: 1, nie
|
||||
// kleiner 1, nie größer als die letzte tatsächlich vorhandene Seite).
|
||||
// per_page ist bewusst nicht vom Nutzer einstellbar (kein Query-Param
|
||||
// dafür) — eine feste Seitengröße hält die Filter-URLs einfach und
|
||||
// verhindert versehentlich riesige Anfragen.
|
||||
func paginationFromRequest(r *http.Request, total int) pagination {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
perPage := defaultPerPage
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
return pagination{
|
||||
Page: page, PerPage: perPage, Total: total, TotalPages: totalPages,
|
||||
HasPrev: page > 1, HasNext: page < totalPages,
|
||||
PrevPage: page - 1, NextPage: page + 1,
|
||||
}
|
||||
}
|
||||
|
||||
// bounds liefert den Start-/End-Index für die aktuelle Seite innerhalb
|
||||
// der (bereits gefilterten) Gesamtliste.
|
||||
func (p pagination) bounds() (start, end int) {
|
||||
start = (p.Page - 1) * p.PerPage
|
||||
end = start + p.PerPage
|
||||
if end > p.Total {
|
||||
end = p.Total
|
||||
}
|
||||
if start > end {
|
||||
start = end
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// matchesQuery prüft, ob q (Freitext-Suche, ohne Beachtung von
|
||||
// Groß-/Kleinschreibung) in mindestens einem der übergebenen Felder
|
||||
// vorkommt — leeres q trifft immer zu. Gemeinsame Basis für die
|
||||
// Freitext-Filter mehrerer Listen-Seiten (Register, Nutzerverwaltung,
|
||||
// Posteingang, ...), damit nicht jede Liste ihre eigene Such-Logik
|
||||
// erfindet.
|
||||
func matchesQuery(q string, felder ...string) bool {
|
||||
if q == "" {
|
||||
return true
|
||||
}
|
||||
q = strings.ToLower(q)
|
||||
for _, f := range felder {
|
||||
if strings.Contains(strings.ToLower(f), q) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// paginate schneidet den Ausschnitt für die aktuelle Seite aus einer
|
||||
// bereits gefilterten/sortierten Liste heraus — generisch, damit jede
|
||||
// Listen-Seite (Werkzeuge, Register, ...) dieselbe Funktion nutzt statt
|
||||
// eigene Slice-Arithmetik zu wiederholen.
|
||||
func paginate[T any](items []T, p pagination) []T {
|
||||
start, end := p.bounds()
|
||||
return items[start:end]
|
||||
}
|
||||
103
internal/web/passwort_reset_handlers.go
Normal file
103
internal/web/passwort_reset_handlers.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Passwort-Zurücksetzen (Ebene 1, öffentlich) — nutzt internal/mail für
|
||||
// den Versand. Wie beim Login verrät keine Fehlermeldung, ob eine
|
||||
// E-Mail-Adresse überhaupt existiert (User-Enumeration-Schutz): die
|
||||
// Anfrage-Seite zeigt immer dieselbe Erfolgsmeldung, unabhängig davon,
|
||||
// ob tatsächlich eine Mail verschickt wurde.
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
const passwortVergessenHinweis = "Falls diese E-Mail-Adresse bei uns registriert ist, wurde ein Link zum Zurücksetzen verschickt."
|
||||
|
||||
func (s *Server) handlePasswortVergessenForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Error: "ungültiges Formular"})
|
||||
return
|
||||
}
|
||||
email := r.FormValue("email")
|
||||
|
||||
user, err := s.store.GetUserByEmail(r.Context(), email)
|
||||
if err == nil && user.Active {
|
||||
token, tokenErr := auth.NewSessionToken()
|
||||
if tokenErr == nil {
|
||||
if _, createErr := s.store.CreatePasswordResetToken(r.Context(), user.ID, token); createErr == nil {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
|
||||
body := "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n" + link + "\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
|
||||
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
|
||||
// (keine Enumeration über unterschiedliches Verhalten bei
|
||||
// Versandfehlern), aber serverseitig geloggt statt
|
||||
// verschluckt — sonst bleibt ein dauerhaft falsch
|
||||
// konfigurierter SMTP-Server unbemerkt.
|
||||
if err := s.mailer.Send(user.Email, "Deklarix — Passwort zurücksetzen", body); err != nil {
|
||||
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Message: passwortVergessenHinweis})
|
||||
}
|
||||
|
||||
type passwortZuruecksetzenData struct {
|
||||
Title string
|
||||
Token string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortZuruecksetzenForm(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if _, err := s.store.GetValidPasswordResetToken(r.Context(), token); err != nil {
|
||||
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
resetToken, err := s.store.GetValidPasswordResetToken(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "ungültiges Formular")
|
||||
return
|
||||
}
|
||||
passwordHash, err := auth.HashPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserPassword(r.Context(), resetToken.UserID, passwordHash); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "Passwort konnte nicht gesetzt werden")
|
||||
return
|
||||
}
|
||||
if err := s.store.MarkPasswordResetTokenUsed(r.Context(), resetToken.ID); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "Passwort wurde gesetzt, Token konnte aber nicht als verwendet markiert werden")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderPasswortZuruecksetzenError(w http.ResponseWriter, token, msg string) {
|
||||
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token, Error: msg}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
94
internal/web/passwort_reset_handlers_test.go
Normal file
94
internal/web/passwort_reset_handlers_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var resetLinkPattern = regexp.MustCompile(`/passwort-zuruecksetzen/([a-f0-9]+)`)
|
||||
|
||||
func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
|
||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
|
||||
"password": {"altes-passwort"},
|
||||
})
|
||||
if regResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303", regResp.Code)
|
||||
}
|
||||
|
||||
vergessenResp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"reset@example.com"}})
|
||||
if vergessenResp.Code != http.StatusOK {
|
||||
t.Fatalf("passwort-vergessen status = %d, want 200", vergessenResp.Code)
|
||||
}
|
||||
if len(fm.Sent) != 1 {
|
||||
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
|
||||
}
|
||||
match := resetLinkPattern.FindStringSubmatch(fm.Sent[0].Body)
|
||||
if match == nil {
|
||||
t.Fatalf("kein Reset-Link in Mail-Body gefunden: %q", fm.Sent[0].Body)
|
||||
}
|
||||
token := match[1]
|
||||
|
||||
getResp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/"+token)
|
||||
if getResp.Code != http.StatusOK {
|
||||
t.Fatalf("get reset form status = %d, want 200", getResp.Code)
|
||||
}
|
||||
|
||||
resetResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"neues-passwort-123"}})
|
||||
if resetResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("reset status = %d, want 303, body: %s", resetResp.Code, resetResp.Body.String())
|
||||
}
|
||||
|
||||
// Derselbe Link darf kein zweites Mal funktionieren.
|
||||
reuseResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"noch-ein-passwort"}})
|
||||
if reuseResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("reuse status = %d, want 404 (Token bereits verwendet)", reuseResp.Code)
|
||||
}
|
||||
|
||||
// Altes Passwort funktioniert nicht mehr.
|
||||
oldLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"altes-passwort"}})
|
||||
if oldLoginResp.Code != http.StatusOK {
|
||||
t.Fatalf("login mit altem Passwort status = %d, want 200 (abgelehnt)", oldLoginResp.Code)
|
||||
}
|
||||
|
||||
// Neues Passwort funktioniert.
|
||||
newLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"neues-passwort-123"}})
|
||||
if newLoginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login mit neuem Passwort status = %d, want 303", newLoginResp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswortVergessenFuerUnbekannteEmailVerraetNichts(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
|
||||
resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"gibt-es-nicht@example.com"}})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.Code)
|
||||
}
|
||||
if len(fm.Sent) != 0 {
|
||||
t.Fatalf("got %d gesendete Mails für unbekannte E-Mail, want 0", len(fm.Sent))
|
||||
}
|
||||
if !containsHinweis(resp.Body.String()) {
|
||||
t.Fatalf("erwartete generische Erfolgsmeldung, body: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func containsHinweis(body string) bool {
|
||||
return regexp.MustCompile(`Falls diese E-Mail-Adresse`).MatchString(body)
|
||||
}
|
||||
|
||||
func TestPasswortZuruecksetzenMitUngueltigemTokenGibt404(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/nicht-existierendes-token")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,11 @@ type registerEintragView struct {
|
||||
}
|
||||
|
||||
type registerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Eintraege []registerEintragView
|
||||
Title string
|
||||
Nav navData
|
||||
Eintraege []registerEintragView
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
||||
@@ -61,7 +63,18 @@ func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := registerListeData{Title: "Register", Nav: navFor(r), Eintraege: views}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []registerEintragView
|
||||
for _, e := range views {
|
||||
if matchesQuery(q, e.Zweck, e.Abteilung, e.Werkzeug, e.Verantwortlicher) {
|
||||
gefiltert = append(gefiltert, e)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := registerListeData{
|
||||
Title: "Register", Nav: navFor(r), Eintraege: paginate(gefiltert, pag),
|
||||
Suche: q, Pagination: pag,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
@@ -39,6 +40,12 @@ type Store interface {
|
||||
GetUser(ctx context.Context, id string) (store.User, error)
|
||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||
SetUserActive(ctx context.Context, id string, active bool) error
|
||||
SetUserPassword(ctx context.Context, id, passwordHash string) error
|
||||
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
|
||||
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
|
||||
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
|
||||
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
|
||||
MarkPasswordResetTokenUsed(ctx context.Context, id string) 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)
|
||||
@@ -76,6 +83,27 @@ type Store interface {
|
||||
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
||||
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
||||
|
||||
CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error)
|
||||
GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error)
|
||||
ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error)
|
||||
DeleteGenehmigerRolle(ctx context.Context, id string) error
|
||||
AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
||||
RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
||||
ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error)
|
||||
ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error)
|
||||
|
||||
CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error)
|
||||
ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error)
|
||||
GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error)
|
||||
DeleteFreigabeRegel(ctx context.Context, id string) error
|
||||
|
||||
CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error)
|
||||
GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error)
|
||||
ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error)
|
||||
ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error)
|
||||
EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error)
|
||||
KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error
|
||||
}
|
||||
|
||||
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||
@@ -94,11 +122,15 @@ type Server struct {
|
||||
store Store
|
||||
regelwerk Regelwerk
|
||||
templates *template.Template
|
||||
mailer mail.Mailer
|
||||
}
|
||||
|
||||
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||
// wird einmal beim Start geladen, nicht pro Request.
|
||||
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
// wird einmal beim Start geladen, nicht pro Request. mailer ist
|
||||
// austauschbar (siehe mail.FakeMailer für Tests) — ohne konfigurierten
|
||||
// SMTP-Server schlägt nur der tatsächliche Versand fehl (z. B.
|
||||
// Passwort-Zurücksetzen), der restliche Server bleibt funktionsfähig.
|
||||
func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||
@@ -108,6 +140,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
store: st,
|
||||
regelwerk: regelwerk,
|
||||
templates: tmpl,
|
||||
mailer: mailer,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -118,6 +151,12 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
|
||||
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
|
||||
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
|
||||
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
||||
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
||||
mux.HandleFunc("POST /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzen)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||
@@ -151,6 +190,16 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||
mux.HandleFunc("GET /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRollenListe))
|
||||
mux.HandleFunc("POST /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRolleCreate))
|
||||
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/loeschen", s.requireAdmin(s.handleGenehmigerRolleDelete))
|
||||
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder", s.requireAdmin(s.handleGenehmigerRolleMitgliedHinzufuegen))
|
||||
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder/{userID}/entfernen", s.requireAdmin(s.handleGenehmigerRolleMitgliedEntfernen))
|
||||
mux.HandleFunc("GET /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelnListe))
|
||||
mux.HandleFunc("POST /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelCreate))
|
||||
mux.HandleFunc("POST /verwaltung/freigabe-regeln/{id}/loeschen", s.requireAdmin(s.handleFreigabeRegelDelete))
|
||||
mux.HandleFunc("GET /freigaben", s.requirePage(s.handleFreigabenListe))
|
||||
mux.HandleFunc("POST /freigaben/{id}/entscheiden", s.requireAPI(s.handleFreigabeEntscheiden))
|
||||
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))
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
@@ -38,21 +39,34 @@ type fakeStore struct {
|
||||
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
|
||||
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
|
||||
|
||||
genehmigerRollen map[string]store.GenehmigerRolle
|
||||
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
||||
freigabeRegeln map[string]store.FreigabeRegel
|
||||
freigabeschritte map[string]store.Freigabeschritt
|
||||
passwordResetTokens map[string]store.PasswordResetToken
|
||||
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
accounts: map[string]store.Account{},
|
||||
users: map[string]store.User{},
|
||||
usersByEmail: map[string]string{},
|
||||
sessions: map[string]store.Session{},
|
||||
abteilungen: map[string][]store.Abteilung{},
|
||||
antraege: map[string]store.Antrag{},
|
||||
werkzeuge: map[string]store.Werkzeug{},
|
||||
bewertungen: map[string][]store.Bewertung{},
|
||||
entscheidungen: map[string][]store.Entscheidung{},
|
||||
registereintraege: map[string][]store.Registereintrag{},
|
||||
werkzeugSperren: map[string][]store.WerkzeugSperre{},
|
||||
accounts: map[string]store.Account{},
|
||||
users: map[string]store.User{},
|
||||
usersByEmail: map[string]string{},
|
||||
sessions: map[string]store.Session{},
|
||||
abteilungen: map[string][]store.Abteilung{},
|
||||
antraege: map[string]store.Antrag{},
|
||||
werkzeuge: map[string]store.Werkzeug{},
|
||||
bewertungen: map[string][]store.Bewertung{},
|
||||
entscheidungen: map[string][]store.Entscheidung{},
|
||||
registereintraege: map[string][]store.Registereintrag{},
|
||||
werkzeugSperren: map[string][]store.WerkzeugSperre{},
|
||||
genehmigerRollen: map[string]store.GenehmigerRolle{},
|
||||
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
||||
freigabeRegeln: map[string]store.FreigabeRegel{},
|
||||
freigabeschritte: map[string]store.Freigabeschritt{},
|
||||
passwordResetTokens: map[string]store.PasswordResetToken{},
|
||||
loeschfristen: map[string]map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +186,73 @@ func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.users[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
f.users[id] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
t := store.PasswordResetToken{
|
||||
ID: f.newID(), UserID: userID, Token: token,
|
||||
ExpiresAt: time.Now().Add(store.PasswordResetTokenDuration), CreatedAt: time.Now(),
|
||||
}
|
||||
f.passwordResetTokens[t.ID] = t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, t := range f.passwordResetTokens {
|
||||
if t.Token == token && t.UsedAt == nil && t.ExpiresAt.After(time.Now()) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return store.PasswordResetToken{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
t, ok := f.passwordResetTokens[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
t.UsedAt = &now
|
||||
f.passwordResetTokens[id] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.loeschfristen[accountID] == nil {
|
||||
f.loeschfristen[accountID] = map[string]int{}
|
||||
}
|
||||
f.loeschfristen[accountID][datenklasseID] = maxTage
|
||||
return store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage, UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.LoeschfristEinstellung
|
||||
for datenklasseID, maxTage := range f.loeschfristen[accountID] {
|
||||
out = append(out, store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -408,9 +489,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
|
||||
@@ -424,10 +505,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
|
||||
@@ -547,6 +628,208 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account
|
||||
return f.registereintraege[accountID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, Beschreibung: beschreibung, CreatedAt: time.Now()}
|
||||
f.genehmigerRollen[g.ID] = g
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
g, ok := f.genehmigerRollen[id]
|
||||
if !ok {
|
||||
return store.GenehmigerRolle{}, store.ErrNotFound
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.GenehmigerRolle
|
||||
for _, g := range f.genehmigerRollen {
|
||||
if g.AccountID == accountID {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.genehmigerRollen[id]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(f.genehmigerRollen, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.nutzerGenehmigerRollen[userID] == nil {
|
||||
f.nutzerGenehmigerRollen[userID] = map[string]bool{}
|
||||
}
|
||||
f.nutzerGenehmigerRollen[userID][genehmigerRolleID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.nutzerGenehmigerRollen[userID], genehmigerRolleID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.GenehmigerRolle
|
||||
for grID := range f.nutzerGenehmigerRollen[userID] {
|
||||
if g, ok := f.genehmigerRollen[grID]; ok {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.GenehmigerMitglied
|
||||
for userID, set := range f.nutzerGenehmigerRollen {
|
||||
if !set[genehmigerRolleID] {
|
||||
continue
|
||||
}
|
||||
if u, ok := f.users[userID]; ok {
|
||||
out = append(out, store.GenehmigerMitglied{UserID: u.ID, Email: u.Email})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fr := store.FreigabeRegel{
|
||||
ID: f.newID(), AccountID: accountID, BedingungTyp: bedingungTyp,
|
||||
BedingungWert: bedingungWert, GenehmigerRolleID: genehmigerRolleID, CreatedAt: time.Now(),
|
||||
}
|
||||
f.freigabeRegeln[fr.ID] = fr
|
||||
return fr, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.FreigabeRegel
|
||||
for _, fr := range f.freigabeRegeln {
|
||||
if fr.AccountID == accountID {
|
||||
out = append(out, fr)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fr, ok := f.freigabeRegeln[id]
|
||||
if !ok {
|
||||
return store.FreigabeRegel{}, store.ErrNotFound
|
||||
}
|
||||
return fr, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.freigabeRegeln[id]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(f.freigabeRegeln, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fs := store.Freigabeschritt{
|
||||
ID: f.newID(), AntragID: antragID, GenehmigerRolleID: genehmigerRolleID,
|
||||
Status: "ausstehend", CreatedAt: time.Now(),
|
||||
}
|
||||
f.freigabeschritte[fs.ID] = fs
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fs, ok := f.freigabeschritte[id]
|
||||
if !ok {
|
||||
return store.Freigabeschritt{}, store.ErrNotFound
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Freigabeschritt
|
||||
for _, fs := range f.freigabeschritte {
|
||||
if fs.AntragID == antragID {
|
||||
out = append(out, fs)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Freigabeschritt
|
||||
for grID := range f.nutzerGenehmigerRollen[userID] {
|
||||
for _, fs := range f.freigabeschritte {
|
||||
if fs.GenehmigerRolleID == grID && fs.Status == "ausstehend" {
|
||||
out = append(out, fs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fs, ok := f.freigabeschritte[id]
|
||||
if !ok || fs.Status != "ausstehend" {
|
||||
return store.Freigabeschritt{}, store.ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
fs.Status, fs.EntschiedenVon, fs.EntschiedenAm, fs.Kommentar = status, &entschiedenVon, &now, kommentar
|
||||
f.freigabeschritte[id] = fs
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for id, fs := range f.freigabeschritte {
|
||||
if fs.AntragID == antragID && fs.Status == "ausstehend" && id != ausloesenderSchrittID {
|
||||
fs.Status = "abgelehnt"
|
||||
now := time.Now()
|
||||
fs.EntschiedenAm = &now
|
||||
fs.Kommentar = "Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde."
|
||||
f.freigabeschritte[id] = fs
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||
|
||||
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||
@@ -573,11 +856,18 @@ func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
||||
|
||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||
t.Helper()
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||
s, _ := newServerWithMailer(t, fs)
|
||||
return s
|
||||
}
|
||||
|
||||
func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMailer) {
|
||||
t.Helper()
|
||||
fm := &mail.FakeMailer{}
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t), fm)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return s
|
||||
return s, fm
|
||||
}
|
||||
|
||||
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||
@@ -733,6 +1023,53 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
|
||||
"password": {"ein-sicheres-passwort"},
|
||||
})
|
||||
if regResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||
}
|
||||
|
||||
var accountID string
|
||||
for _, u := range fs.users {
|
||||
if u.Email == "neu2@example.com" {
|
||||
accountID = u.AccountID
|
||||
}
|
||||
}
|
||||
if accountID == "" {
|
||||
t.Fatal("neuer Nutzer nicht gefunden")
|
||||
}
|
||||
|
||||
rollen, err := fs.ListGenehmigerRollenForAccount(context.Background(), accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListGenehmigerRollenForAccount: %v", err)
|
||||
}
|
||||
wantNamen := []string{"Datenschutzbeauftragter", "Geschäftsführer", "KI-Manager", "CISO"}
|
||||
if len(rollen) != len(wantNamen) {
|
||||
t.Fatalf("got %d Genehmiger-Rollen, want %d", len(rollen), len(wantNamen))
|
||||
}
|
||||
var namen []string
|
||||
for _, r := range rollen {
|
||||
namen = append(namen, r.Name)
|
||||
}
|
||||
for _, want := range wantNamen {
|
||||
found := false
|
||||
for _, n := range namen {
|
||||
if n == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Standard-Rolle %q wurde nicht angelegt, vorhanden: %v", want, namen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
@@ -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 {
|
||||
@@ -392,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;
|
||||
@@ -422,6 +440,7 @@ nav button {
|
||||
.beitraege-liste > li:not(:has(> a)) form {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.beitraege-liste > li:not(:has(> a)) button {
|
||||
@@ -532,8 +551,8 @@ button.entfernen {
|
||||
}
|
||||
|
||||
.fragebogen legend {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@@ -545,6 +564,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;
|
||||
@@ -595,11 +738,242 @@ 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%);
|
||||
}
|
||||
|
||||
/* Filter-/Paginierungsleiste für Listen-Seiten (Werkzeugkatalog,
|
||||
Register, ...) — ein einziges GET-Formular ohne JavaScript, siehe
|
||||
pagination.go. */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
/* explizit setzen, nicht dem initial-value überlassen: .filter-bar
|
||||
landet manchmal direkt auf einem <form>, und "form { flex-direction:
|
||||
column }" (oben) hat dieselbe Spezifität für DIESE Property, wenn
|
||||
.filter-bar sie nicht selbst deklariert — das Formular würde dann
|
||||
senkrecht stapeln statt in einer Zeile. */
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-bar input[type="search"] {
|
||||
flex: 1 1 220px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
flex: 0 1 200px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.filter-bar button {
|
||||
margin-top: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
margin-top: 0;
|
||||
min-height: 36px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Responsive Tabellen — Card-Ansicht auf dem Handy ────────────────
|
||||
enconf-Pattern (ProTable.tsx): auf schmalen Screens wird nicht
|
||||
horizontal gescrollt, sondern jede Zeile zu einer eigenen Karte mit
|
||||
Label/Wert-Paaren. Braucht data-label="Spaltenname" auf jedem <td>.
|
||||
Ab 640px bleibt es eine normale Tabelle (mit Scroll-Fade als
|
||||
Sicherheitsnetz bei vielen Spalten). */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.table-responsive {
|
||||
overflow-x: visible;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.table-responsive table,
|
||||
.table-responsive tbody,
|
||||
.table-responsive tr,
|
||||
.table-responsive td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-responsive thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.table-responsive tbody tr {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.table-responsive td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
text-align: right;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-responsive td:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-responsive td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
text-align: left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Seitenkopf (PageHeader-Pattern, enconf) ─────────────────────────
|
||||
Titel + Unterzeile links, primäre Aktion rechts — statt eines
|
||||
nackten <h1> mit einem Link irgendwo unten auf der Seite. */
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header .hinweis {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.page-header .page-header-action {
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--branding-primary);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.page-header .page-header-action:hover {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
/* ── Kennzahl-Kacheln (StatCard-Pattern, enconf Dashboard) ───────────
|
||||
Vorher: einfache Text-Links in einer Liste ohne visuelle Priorität.
|
||||
Jetzt: farbiger oberer Akzentrand + große Zahl + Klick navigiert zur
|
||||
Liste, wie enconfs StatCard-Komponente — nur ohne Icon-Bibliothek. */
|
||||
.stat-cards {
|
||||
list-style: none;
|
||||
margin: 0 0 24px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: block;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-top: 3px solid var(--branding-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 16px 18px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-card-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-card-mittel {
|
||||
border-top-color: var(--color-mittel);
|
||||
}
|
||||
|
||||
.stat-card-hoch {
|
||||
border-top-color: var(--color-hoch);
|
||||
}
|
||||
|
||||
.stat-card-action {
|
||||
background: var(--branding-primary);
|
||||
border-color: var(--branding-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stat-card-action .stat-card-label {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Abteilungen</h1>
|
||||
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Abteilungen</h1>
|
||||
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if not .Abteilungen}}
|
||||
<p class="hinweis">Noch keine Abteilungen.</p>
|
||||
|
||||
@@ -4,22 +4,42 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Meine Anträge</h1>
|
||||
{{if not .Antraege}}
|
||||
<p class="hinweis">Noch keine Anträge gestellt.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Antraege}}
|
||||
<li>
|
||||
<a href="/antraege/{{.ID}}">
|
||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
|
||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||
</a>
|
||||
</li>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Meine Anträge</h1>
|
||||
<p class="hinweis">Eigene Anträge und ihr Bearbeitungsstand.</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/antraege/neu">Neuen Antrag stellen</a>
|
||||
</div>
|
||||
<form method="get">
|
||||
<div class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Status, Häufigkeit)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/antraege">Zurücksetzen</a>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .Antraege}}
|
||||
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Anträge gestellt.{{end}}</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Antraege}}
|
||||
<li>
|
||||
<a href="/antraege/{{.ID}}">
|
||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
|
||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">← Zurück</button>{{end}}
|
||||
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,9 +5,20 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<h1>Accounts</h1>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Accounts</h1>
|
||||
<p class="hinweis">Alle Mandanten der Plattform.</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/betreiber/accounts/neu">Firma anlegen</a>
|
||||
</div>
|
||||
<form method="get" class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Firmenname)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/betreiber/accounts">Zurücksetzen</a>{{end}}
|
||||
</form>
|
||||
{{if not .Accounts}}
|
||||
<p class="hinweis">Noch keine Accounts.</p>
|
||||
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Accounts.{{end}}</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Accounts}}
|
||||
@@ -18,8 +29,26 @@
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Suche}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
|
||||
<button type="submit">← Zurück</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Pagination.HasNext}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Suche}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
|
||||
<button type="submit">Weiter →</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,9 +5,19 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<h1>Audit-Log</h1>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Audit-Log</h1>
|
||||
<p class="hinweis">Plattformweit, append-only — zeigt die neuesten Einträge.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Aktion, Zieltyp, Details)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/betreiber/audit-log">Zurücksetzen</a>{{end}}
|
||||
</form>
|
||||
{{if not .Entries}}
|
||||
<p class="hinweis">Noch keine Einträge.</p>
|
||||
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Einträge.{{end}}</p>
|
||||
{{else}}
|
||||
<ul class="beteiligte">
|
||||
{{range .Entries}}
|
||||
@@ -17,6 +27,25 @@
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Suche}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
|
||||
<button type="submit">← Zurück</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Pagination.HasNext}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Suche}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
|
||||
<button type="submit">Weiter →</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -5,10 +5,25 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Plattform</h1>
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
||||
<li><a href="/betreiber/werkzeuge">Werkzeugkatalog <span class="status">{{.WerkzeugCount}}</span></a></li>
|
||||
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
||||
<ul class="stat-cards">
|
||||
<li>
|
||||
<a class="stat-card" href="/betreiber/accounts">
|
||||
<span class="stat-card-value">{{.AccountCount}}</span>
|
||||
<span class="stat-card-label">Accounts</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="stat-card" href="/betreiber/werkzeuge">
|
||||
<span class="stat-card-value">{{.WerkzeugCount}}</span>
|
||||
<span class="stat-card-label">Werkzeugkatalog</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="stat-card" href="/betreiber/audit-log">
|
||||
<span class="stat-card-value">{{.RecentAuditCount}}</span>
|
||||
<span class="stat-card-label">Audit-Log (neueste)</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -7,66 +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}}">
|
||||
|
||||
<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 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}}">
|
||||
|
||||
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
||||
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
||||
|
||||
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
|
||||
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
|
||||
|
||||
<fieldset>
|
||||
<legend>Geeignete Zwecke</legend>
|
||||
{{range .ZweckeOptionen}}
|
||||
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
|
||||
{{end}}
|
||||
<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 for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||
<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="letzte_pruefung">Letzte Prüfung</label>
|
||||
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
||||
<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="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}}
|
||||
|
||||
@@ -5,38 +5,66 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
|
||||
{{else}}
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
|
||||
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
|
||||
<th>Zuletzt geprüft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Werkzeuge}}
|
||||
<tr>
|
||||
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungsort}}</td>
|
||||
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{.Aufbewahrung}}</td>
|
||||
<td>{{.Zertifizierungen}}</td>
|
||||
<td>{{.GeeigneteZwecke}}</td>
|
||||
<td>{{.LetztePruefung}}{{if .NichtAktuell}} <span class="status">nicht aktuell geprüft</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
<p class="hinweis">Zentral gepflegt, für alle Mandanten sichtbar.</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
||||
<form method="get">
|
||||
<div class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
|
||||
<select name="zweck">
|
||||
<option value="">Alle Zwecke</option>
|
||||
{{$zweck := .Filter.Zweck}}
|
||||
{{range .ZweckeOptionen}}
|
||||
<option value="{{.}}" {{if eq . $zweck}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Filter.Aktiv}}<a href="/betreiber/werkzeuge">Zurücksetzen</a>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Noch keine zentralen Katalogeinträge.{{end}}</p>
|
||||
{{else}}
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</th><th>AVV</th>
|
||||
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
|
||||
<th>Zuletzt geprüft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Werkzeuge}}
|
||||
<tr>
|
||||
<td data-label="Name"><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||
<td data-label="DPF">{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
|
||||
<td data-label="Zertifizierungen">{{.Zertifizierungen}}</td>
|
||||
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
|
||||
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">← Zurück</button>{{end}}
|
||||
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,20 +4,40 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Posteingang</h1>
|
||||
{{if not .Faelle}}
|
||||
<p class="keine-findings">Keine offenen Anträge — alles bearbeitet.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Faelle}}
|
||||
<li>
|
||||
<a href="/faelle/{{.ID}}">
|
||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · eingereicht am {{.CreatedAt}}
|
||||
</a>
|
||||
</li>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Posteingang</h1>
|
||||
<p class="hinweis">Alle noch offenen Anträge deines Mandanten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get">
|
||||
<div class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Häufigkeit)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/faelle">Zurücksetzen</a>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .Faelle}}
|
||||
<p class="keine-findings">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Keine offenen Anträge — alles bearbeitet.{{end}}</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Faelle}}
|
||||
<li>
|
||||
<a href="/faelle/{{.ID}}">
|
||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · eingereicht am {{.CreatedAt}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">← Zurück</button>{{end}}
|
||||
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -33,6 +33,23 @@
|
||||
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
|
||||
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
|
||||
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
|
||||
|
||||
{{if .Freigabeschritte}}
|
||||
<h2>Zusätzliche Freigaben</h2>
|
||||
{{if eq .Status "wartet_auf_freigabe"}}
|
||||
<p class="hinweis">Die Genehmigung ist erst endgültig, wenn alle unten stehenden Freigaben erteilt sind.</p>
|
||||
{{end}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Freigabeschritte}}
|
||||
<li>
|
||||
{{.RolleName}}
|
||||
<span class="status {{if eq .Status "genehmigt"}}status-published{{else if eq .Status "abgelehnt"}}status-hoch{{else}}status-mittel{{end}}">{{.Status}}</span>
|
||||
{{if .Kommentar}}<p>{{.Kommentar}}</p>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{else if .CanEntscheiden}}
|
||||
<h2>Entscheiden</h2>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
|
||||
93
internal/web/templates/freigabe_regeln_liste.html
Normal file
93
internal/web/templates/freigabe_regeln_liste.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{{define "freigabe-regeln-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Freigabe-Regeln</h1>
|
||||
<p class="hinweis">
|
||||
"Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||
Genehmiger-Rolle Y nötig." Bedingungen sind die vom Regelwerk
|
||||
abgeleiteten Anforderungen/Einstufungen/Datenklassen — keine
|
||||
freie Regel, sondern eine Auswahl aus dem, was das System ohnehin
|
||||
schon berechnet. Eine Genehmigung durch die Fachebene wartet dann
|
||||
erst auf diese zusätzlichen Freigaben, bevor sie endgültig ist;
|
||||
lehnt eine davon ab, gilt der gesamte Antrag als abgelehnt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
|
||||
{{if not .RollenOptionen}}
|
||||
<p class="hinweis">
|
||||
Zuerst mindestens eine <a href="/verwaltung/genehmiger-rollen">Genehmiger-Rolle</a>
|
||||
anlegen, bevor eine Freigabe-Regel definiert werden kann.
|
||||
</p>
|
||||
{{else}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="/verwaltung/freigabe-regeln">
|
||||
<fieldset class="form-section">
|
||||
<legend>Neue Freigabe-Regel</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="bedingung">Bedingung</label>
|
||||
<select id="bedingung" name="bedingung" required>
|
||||
<option value="">Bitte wählen…</option>
|
||||
{{range .BedingungGruppen}}
|
||||
{{$gruppe := .Label}}
|
||||
<optgroup label="{{$gruppe}}">
|
||||
{{range .Optionen}}
|
||||
<option value="{{.Value}}">{{.Label}}</option>
|
||||
{{end}}
|
||||
</optgroup>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="genehmiger_rolle_id">Genehmiger-Rolle</label>
|
||||
<select id="genehmiger_rolle_id" name="genehmiger_rolle_id" required>
|
||||
<option value="">Bitte wählen…</option>
|
||||
{{range .RollenOptionen}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .Regeln}}
|
||||
<p class="hinweis">Noch keine Freigabe-Regeln.</p>
|
||||
{{else}}
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Bedingung</th><th>Erforderliche Freigabe</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Regeln}}
|
||||
<tr>
|
||||
<td data-label="Bedingung">{{.BedingungLabel}}</td>
|
||||
<td data-label="Erforderliche Freigabe">{{.GenehmigerRolle}}</td>
|
||||
<td data-label="">
|
||||
<form method="post" action="/verwaltung/freigabe-regeln/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Freigabe-Regel wirklich löschen?')">
|
||||
<button type="submit" class="entfernen">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
38
internal/web/templates/freigaben_liste.html
Normal file
38
internal/web/templates/freigaben_liste.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{{define "freigaben-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Meine Freigaben</h1>
|
||||
<p class="hinweis">Anträge, die zusätzlich zur Fachebene-Entscheidung deine Freigabe brauchen.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Freigaben}}
|
||||
<p class="keine-findings">Keine ausstehenden Freigaben.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Freigaben}}
|
||||
<li>
|
||||
<div class="beteiligter-kopf">
|
||||
<strong>{{.AntragTitel}}</strong> · Freigabe als {{.RolleName}} · seit {{.CreatedAt}}
|
||||
</div>
|
||||
<form method="post" action="/freigaben/{{.ID}}/entscheiden">
|
||||
<label for="kommentar-{{.ID}}">Kommentar (optional, Pflicht bei Ablehnung empfohlen)</label>
|
||||
<input type="text" id="kommentar-{{.ID}}" name="kommentar">
|
||||
<div class="form-actions">
|
||||
<button type="submit" name="entscheidung" value="genehmigt">Freigeben</button>
|
||||
<button type="submit" name="entscheidung" value="abgelehnt" class="entfernen" onclick="return confirm('Wirklich ablehnen? Das lehnt den gesamten Antrag ab.')">Ablehnen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p><a href="/faelle/{{.AntragID}}">Antrag ansehen →</a></p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
83
internal/web/templates/genehmiger_rollen_liste.html
Normal file
83
internal/web/templates/genehmiger_rollen_liste.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{{define "genehmiger-rollen-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Genehmiger-Rollen</h1>
|
||||
<p class="hinweis">
|
||||
Freigabe-Funktionen wie "Datenschutzbeauftragter" oder
|
||||
"Geschäftsführung" — unabhängig von den normalen Zugriffsrollen,
|
||||
eine Person kann beides zugleich sein. Wer wann zusätzlich
|
||||
freigeben muss, legst du unter
|
||||
<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a> fest.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
|
||||
<div class="form-card">
|
||||
<form method="post" action="/verwaltung/genehmiger-rollen">
|
||||
<fieldset class="form-section">
|
||||
<legend>Neue Genehmiger-Rolle</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="beschreibung">Beschreibung (optional)</label>
|
||||
<input type="text" id="beschreibung" name="beschreibung" placeholder="z. B. Prüft den Antrag aus Sicherheitssicht">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if not .Rollen}}
|
||||
<p class="hinweis">Noch keine Genehmiger-Rollen.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Rollen}}
|
||||
{{$rolleID := .ID}}
|
||||
<li>
|
||||
<div class="beteiligter-kopf"><strong>{{.Name}}</strong>{{if .Beschreibung}} — {{.Beschreibung}}{{end}}</div>
|
||||
{{if .Mitglieder}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Mitglieder}}
|
||||
<li>
|
||||
{{.Email}}
|
||||
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder/{{.ID}}/entfernen" style="display:inline">
|
||||
<button type="submit" class="entfernen">Entfernen</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="hinweis">Noch keine Mitglieder.</p>
|
||||
{{end}}
|
||||
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder" style="display:inline">
|
||||
<select name="user_id" required>
|
||||
<option value="">Person hinzufügen…</option>
|
||||
{{range $.Nutzer}}
|
||||
<option value="{{.ID}}">{{.Email}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/loeschen" style="display:inline" onsubmit="return confirm('Genehmiger-Rolle wirklich löschen?')">
|
||||
<button type="submit" class="entfernen">Rolle löschen</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -6,15 +6,25 @@
|
||||
<div class="page">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
{{if .Nav.IsBetreiber}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber">Zur Plattform</a></li>
|
||||
<ul class="stat-cards">
|
||||
<li><a class="stat-card stat-card-action" href="/betreiber"><span class="stat-card-label">Zur Plattform →</span></a></li>
|
||||
</ul>
|
||||
{{else}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||
<li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</span></a></li>
|
||||
<ul class="stat-cards">
|
||||
<li><a class="stat-card stat-card-action" href="/antraege/neu"><span class="stat-card-label">Antrag stellen →</span></a></li>
|
||||
<li>
|
||||
<a class="stat-card{{if .OffeneAntraege}} stat-card-mittel{{end}}" href="/antraege">
|
||||
<span class="stat-card-value">{{.OffeneAntraege}}</span>
|
||||
<span class="stat-card-label">Meine offenen Anträge</span>
|
||||
</a>
|
||||
</li>
|
||||
{{if .ZeigePosteingang}}
|
||||
<li><a href="/faelle">Posteingang <span class="status">{{.OffenerPosteingang}}</span></a></li>
|
||||
<li>
|
||||
<a class="stat-card{{if .OffenerPosteingang}} stat-card-hoch{{end}}" href="/faelle">
|
||||
<span class="stat-card-value">{{.OffenerPosteingang}}</span>
|
||||
<span class="stat-card-label">Posteingang</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<nav class="app-nav">
|
||||
<a href="/">Start</a>
|
||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||
{{if not .IsBetreiber}}<a href="/freigaben">Meine Freigaben</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
|
||||
@@ -31,6 +32,9 @@
|
||||
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
43
internal/web/templates/loeschfristen_liste.html
Normal file
43
internal/web/templates/loeschfristen_liste.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "loeschfristen-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Löschfristen</h1>
|
||||
<p class="hinweis">
|
||||
Maximale Aufbewahrungsdauer je Datenklasse, gegen die ein Werkzeug
|
||||
hart geprüft wird (Anforderung <code>loeschfrist_max_tage</code>).
|
||||
Die DSGVO nennt keine festen Fristen, nur den Grundsatz "so lange
|
||||
wie für den Zweck nötig" — die Werte unten sind ein Vorschlag,
|
||||
keine Rechtsvorgabe, und sollten vom Datenschutzbeauftragten
|
||||
geprüft und bei Bedarf angepasst werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Gespeichert}}<p class="hinweis">Löschfristen gespeichert.</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="/verwaltung/loeschfristen">
|
||||
<fieldset class="form-section">
|
||||
<legend>Maximale Aufbewahrungsdauer (Tage)</legend>
|
||||
<div class="form-grid">
|
||||
{{range .Zeilen}}
|
||||
<div>
|
||||
<label for="max_tage_{{.DatenklasseID}}">{{.DatenklasseID}}</label>
|
||||
<input type="number" min="0" id="max_tage_{{.DatenklasseID}}" name="max_tage_{{.DatenklasseID}}" value="{{.MaxTage}}" required>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="page">
|
||||
<h1>Anmelden</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
@@ -15,6 +16,7 @@
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,35 +6,51 @@
|
||||
<div class="page">
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
|
||||
<h2>Zentraler Katalog</h2>
|
||||
<p class="hinweis">
|
||||
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
||||
der Werkzeugsuche ausgeblendet.
|
||||
</p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Zentraler Katalog</h2>
|
||||
<p class="hinweis">
|
||||
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten
|
||||
aus der Werkzeugsuche ausgeblendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
|
||||
<select name="zweck">
|
||||
<option value="">Alle Zwecke</option>
|
||||
{{$zweck := .Filter.Zweck}}
|
||||
{{range .ZweckeOptionen}}
|
||||
<option value="{{.}}" {{if eq . $zweck}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Filter.Aktiv}}<a href="/verwaltung/werkzeuge">Zurücksetzen</a>{{end}}
|
||||
</form>
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
||||
<p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Der zentrale Katalog ist noch leer.{{end}}</p>
|
||||
{{else}}
|
||||
<div class="table-scroll">
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
|
||||
<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>{{.Verarbeitungsort}}</td>
|
||||
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{.Aufbewahrung}}</td>
|
||||
<td>{{.GeeigneteZwecke}}</td>
|
||||
<td>{{.LetztePruefung}}{{if .NichtAktuell}} <span class="status">nicht aktuell geprüft</span>{{end}}</td>
|
||||
<td>
|
||||
<td data-label="Name">{{.Name}}</td>
|
||||
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
|
||||
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
|
||||
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||
<td data-label="Status">
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
@@ -52,35 +68,60 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Filter.Suche}}">
|
||||
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
|
||||
<button type="submit">← Zurück</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Pagination.HasNext}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Filter.Suche}}">
|
||||
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
|
||||
<button type="submit">Weiter →</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h2>Eigene Werkzeuge</h2>
|
||||
<p class="hinweis">
|
||||
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
||||
hier, nicht im zentralen Katalog der Plattform.
|
||||
</p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Eigene Werkzeuge</h2>
|
||||
<p class="hinweis">
|
||||
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
||||
hier, nicht im zentralen Katalog der Plattform.
|
||||
</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a>
|
||||
</div>
|
||||
{{if not .EigeneWerkzeuge}}
|
||||
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
||||
{{else}}
|
||||
<div class="table-scroll">
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>Zuletzt geprüft</th></tr>
|
||||
<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>{{.Verarbeitungsort}}</td>
|
||||
<td>{{.LetztePruefung}}</td>
|
||||
<td data-label="Name"><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||
<td data-label="Zuletzt geprüft">{{.LetztePruefung}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Nutzerverwaltung</h1>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Nutzerverwaltung</h1>
|
||||
<p class="hinweis">Logins deines Mandanten — ein Login wird deaktiviert statt gelöscht.</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/verwaltung/nutzer/neu">Nutzer anlegen</a>
|
||||
</div>
|
||||
<form method="get" class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (E-Mail, Rolle)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/verwaltung/nutzer">Zurücksetzen</a>{{end}}
|
||||
</form>
|
||||
{{if not .Nutzer}}
|
||||
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine weiteren Nutzer.{{end}}</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Nutzer}}
|
||||
<li>
|
||||
@@ -25,7 +39,27 @@
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a> · <a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Suche}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
|
||||
<button type="submit">← Zurück</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Pagination.HasNext}}
|
||||
<form method="get" style="display:inline">
|
||||
<input type="hidden" name="q" value="{{.Suche}}">
|
||||
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
|
||||
<button type="submit">Weiter →</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
internal/web/templates/passwort_vergessen.html
Normal file
18
internal/web/templates/passwort_vergessen.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{define "passwort-vergessen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Passwort vergessen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/passwort-vergessen">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
<button type="submit">Link anfordern</button>
|
||||
</form>
|
||||
<p><a href="/login">Zurück zur Anmeldung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
16
internal/web/templates/passwort_zuruecksetzen.html
Normal file
16
internal/web/templates/passwort_zuruecksetzen.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{define "passwort-zuruecksetzen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Neues Passwort setzen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/passwort-zuruecksetzen/{{.Token}}">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
<button type="submit">Passwort setzen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -4,36 +4,61 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Register</h1>
|
||||
<p class="hinweis">
|
||||
Jede Genehmigung erzeugt automatisch einen Registereintrag —
|
||||
abgelehnte Anträge und Rückfragen stehen nicht im Register.
|
||||
</p>
|
||||
<p><a href="/registereintraege/export.csv">CSV-Export</a> · <a href="/registereintraege/export.pdf">PDF-Export</a></p>
|
||||
{{if not .Eintraege}}
|
||||
<p class="hinweis">Noch keine Genehmigungen.</p>
|
||||
{{else}}
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zweck</th><th>Abteilung</th><th>Werkzeug</th><th>Datenklasse</th>
|
||||
<th>Einstufung</th><th>Auflagen</th><th>Verantwortliche/r</th>
|
||||
<th>Entschieden am</th><th>Gültig bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Eintraege}}
|
||||
<tr>
|
||||
<td>{{.Zweck}}</td><td>{{.Abteilung}}</td><td>{{.Werkzeug}}</td><td>{{.Datenklasse}}</td>
|
||||
<td>{{.Einstufung}}</td><td>{{.Auflagen}}</td><td>{{.Verantwortlicher}}</td>
|
||||
<td>{{.EntschiedenAm}}</td><td>{{.GueltigBis}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Register</h1>
|
||||
<p class="hinweis">
|
||||
Jede Genehmigung erzeugt automatisch einen Registereintrag —
|
||||
abgelehnte Anträge und Rückfragen stehen nicht im Register.
|
||||
</p>
|
||||
</div>
|
||||
<span class="hinweis"><a href="/registereintraege/export.csv">CSV-Export</a> · <a href="/registereintraege/export.pdf">PDF-Export</a></span>
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="get">
|
||||
<div class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Zweck, Abteilung, Werkzeug, Verantwortliche/r)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/registereintraege">Zurücksetzen</a>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .Eintraege}}
|
||||
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Genehmigungen.{{end}}</p>
|
||||
{{else}}
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zweck</th><th>Abteilung</th><th>Werkzeug</th><th>Datenklasse</th>
|
||||
<th>Einstufung</th><th>Auflagen</th><th>Verantwortliche/r</th>
|
||||
<th>Entschieden am</th><th>Gültig bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Eintraege}}
|
||||
<tr>
|
||||
<td data-label="Zweck">{{.Zweck}}</td>
|
||||
<td data-label="Abteilung">{{.Abteilung}}</td>
|
||||
<td data-label="Werkzeug">{{.Werkzeug}}</td>
|
||||
<td data-label="Datenklasse">{{.Datenklasse}}</td>
|
||||
<td data-label="Einstufung">{{.Einstufung}}</td>
|
||||
<td data-label="Auflagen">{{.Auflagen}}</td>
|
||||
<td data-label="Verantwortliche/r">{{.Verantwortlicher}}</td>
|
||||
<td data-label="Entschieden am">{{.EntschiedenAm}}</td>
|
||||
<td data-label="Gültig bis">{{.GueltigBis}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||
<span>
|
||||
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">← Zurück</button>{{end}}
|
||||
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Wiedervorlage</h1>
|
||||
<p class="hinweis">
|
||||
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
|
||||
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
|
||||
</p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Wiedervorlage</h1>
|
||||
<p class="hinweis">
|
||||
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
|
||||
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Eintraege}}
|
||||
<p class="keine-findings">Nichts zur Wiedervorlage.</p>
|
||||
{{else}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,3 +15,12 @@ RULES_DIR=/usr/share/deklarix/rules
|
||||
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||
# Schleife gegen einen Platzhalter laufen zu lassen.
|
||||
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
|
||||
|
||||
# Optional — ohne SMTP_HOST startet der Dienst normal, nur der
|
||||
# tatsächliche E-Mail-Versand (aktuell: Passwort-Zurücksetzen) schlägt
|
||||
# dann sichtbar fehl statt eine nie ankommende Mail vorzutäuschen.
|
||||
#SMTP_HOST=smtp.example.com
|
||||
#SMTP_PORT=587
|
||||
#SMTP_USER=
|
||||
#SMTP_PASSWORD=
|
||||
#SMTP_FROM=deklarix@example.com
|
||||
|
||||
@@ -48,18 +48,28 @@ 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
|
||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse — GELÖST (2026-09-01, konfigurierbar statt fest)
|
||||
|
||||
Die Spezifikation nennt `loeschfrist_max_tage` als Anforderung "aus
|
||||
Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
|
||||
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
||||
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
||||
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
||||
Tage") müssen vor Phase 3 fachlich festgelegt werden. **Auswirkung auf
|
||||
Schritt 3 (`internal/rules.ErfuelltAnforderung`):** ohne Zahlenwerte
|
||||
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.
|
||||
Ursprünglich war unklar, wer die konkreten Tageswerte je Datenklasse
|
||||
festlegt. Recherche (2026-09-01) ergab: die DSGVO selbst nennt **keine**
|
||||
festen Speicherfristen — Art. 5 Abs. 1 lit. e verlangt nur, Daten "so
|
||||
lange wie für den Zweck nötig" zu speichern (Grundsatz der
|
||||
Speicherbegrenzung). Es gibt daher keinen einzelnen "richtigen"
|
||||
Zahlenwert, den man fest in `rules/anforderungen.yaml` hätte eintragen
|
||||
können. Lösung: **pro Mandant einstellbar**, vom Datenschutzbeauftragten
|
||||
festgelegt (`loeschfrist_einstellung`, Migration 0020, `GET/POST
|
||||
/verwaltung/loeschfristen`) statt einer globalen YAML-Konstante. Jede
|
||||
neue Firma wird mit risikogestaffelten Vorschlagswerten vorbelegt
|
||||
(`oeffentlich` 365 / `intern` 180 / `auftragsdaten` 90 / `personenbezogen`
|
||||
90 / `berufsgeheimnis` 30 / `besondere_kategorie` 30 Tage) — eigene
|
||||
Einschätzung, keine Rechtsvorgabe, frei editierbar.
|
||||
|
||||
**Auswirkung auf Schritt 3 (`internal/rules.ErfuelltAnforderung`):** die
|
||||
Anforderung wird jetzt hart gefiltert, sobald für die betroffene
|
||||
Datenklasse eine Frist konfiguriert ist — ein Werkzeug ohne bezifferte
|
||||
Aufbewahrungsdauer erfüllt eine gesetzte Frist NICHT (fail closed, wie
|
||||
bei `eu_verarbeitung`). Ist keine Frist konfiguriert, bleibt das
|
||||
Verhalten wie zuvor (nicht gefiltert).
|
||||
|
||||
## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user