Compare commits

...

23 Commits

Author SHA1 Message Date
noroot
fa5e68c892 refactor: E-Mail-Vorlagen als Liste + eigene Bearbeiten-Seite statt Formular-Stapel
Auf Nutzerfeedback: die erste Version zeigte alle bekannten Vorlagen als
gestapelte Formulare auf einer Seite. Jetzt wie Werkzeugkatalog/
Nutzerverwaltung: eine Liste (GET .../email-vorlagen) mit Klick auf eine
eigene Bearbeiten-Seite je Typ (GET/POST .../email-vorlagen/{typ}) -
skaliert sauber, wenn mit der Zeit weitere Benachrichtigungstypen
dazukommen, statt eine immer länger werdende Formular-Stapel-Seite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:10:46 +02:00
noroot
d3b171f721 feat: pro Mandant editierbare E-Mail-Vorlagen
Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten
Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die
eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform-
Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell
existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding.

Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert
bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes
Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu
aktualisieren. Fix: zwei partielle Unique-Indizes statt eines
gemeinsamen Constraints, mit Regressionstest abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 10:06:09 +02:00
noroot
b0d6b00045 fix: Migration 0021 crashte in Produktion — falsche Rollen-Annahme
v0.38.0 verursachte einen ~3-minütigen Ausfall: Migration 0021 nahm an,
die Anwendung verbinde überall als Postgres-Superuser "postgres" (nur
lokal via sudo geprüft, nicht die tatsächliche Produktions-DATABASE_URL)
und versuchte dort eine neue Rolle anzulegen - production verbindet
aber über die nicht-privilegierte, tabellenbesitzende Rolle "deklarix"
ohne CREATEROLE, das INSERT/CREATE ROLE schlug fehl und der Dienst
crash-loopte im "dirty migration"-Zustand. Kein Datenverlust (Postgres
hat die DDL-Transaktion selbst zurückgerollt), Dienst wurde auf v0.37.0
zurückgestuft und stabilisiert.

Fix: Rollen-Anlage ist jetzt an eine CREATEROLE-Prüfung gekoppelt und
wird bei fehlender Berechtigung übersprungen statt zu scheitern.
Zusätzlich FORCE ROW LEVEL SECURITY auf jeder Tabelle - das bindet auch
den Tabellenbesitzer (wie Productions "deklarix"), ganz ohne die
zusätzliche Rolle. Produktivbetrieb braucht dadurch jetzt gar keinen
manuellen Schritt mehr. Erneut end-to-end verifiziert, diesmal
zusätzlich produktionsgetreu simuliert (temporäre nicht-privilegierte,
tabellenbesitzende Rolle lokal).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:38:51 +02:00
noroot
fe28278615 feat: Row-Level-Security für Mandantenisolation auf DB-Ebene
Postgres-RLS-Policies auf allen Tabellen mit echten Mandanten-
Geschäftsdaten (antrag und alles darüber verkettete, abteilung,
werkzeug/werkzeug_sperre, genehmiger_rolle/freigabe_regel,
loeschfrist_einstellung). Kritischer Fund vor der Umsetzung: die
Anwendung verbindet als postgres-Superuser, der RLS immer umgeht -
Migration 0021 legt deshalb zusätzlich eine eingeschränkte Rolle
"deklarix_app" an, nur für die greifen die Policies tatsächlich.

internal/store/tenant_scope.go: WithTenantScope öffnet eine Transaktion
und setzt Sitzungsvariablen (app.account_id/app.is_betreiber) per
set_config mit Parameterbindung; alle Store-Methoden laufen jetzt über
s.db(ctx) statt direkt s.Pool. Jede require*-Middleware umschließt die
komplette Handler-Ausführung damit - jeder Request läuft dadurch auch
atomar in einer Transaktion (positiver Nebeneffekt).

Live end-to-end verifiziert (echter HTTP-Server + DATABASE_URL_APP auf
die eingeschränkte Rolle gesetzt): zwei Firmen registriert, Isolation
über Abteilung/Antrag/Bewertung bestätigt, zentraler NULL-Katalog für
beide sichtbar. Produktivbetrieb braucht noch einen manuellen Schritt
(Passwort für deklarix_app setzen + DATABASE_URL_APP konfigurieren,
siehe CLAUDE.md) - die Migration allein aktiviert noch nichts, solange
die App weiter als Superuser verbindet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:22:20 +02:00
noroot
0fe29f8c80 fix: .page-wide auf alle Listen-/Dashboard-/Detailansichten ausgeweitet
Erste Version deckte nur die fünf Tabellen-Seiten ab. Auf Nutzerwunsch
jetzt auch Dashboard, Posteingang, Register, Nutzerverwaltung, Fall-/
Antrag-Detail, Wiedervorlage, Betreiber-Accounts/-Audit-Log/-Dashboard
und die neuen Genehmiger-Rollen/Freigaben-Seiten. Reine Formular-/Auth-
Seiten (Login, Registrieren, Einladung, Nutzer/Firma anlegen) bleiben
bewusst bei 1100px - ein einzelnes Formular auf voller Breite wäre
schlechter lesbar. .page-wide selbst wurde von "kein Limit" auf 1600px
korrigiert, da Detailseiten mit Fließtext (Fall-/Antrag-Detail) sonst
auf Ultrawide-Monitoren unlesbar lange Zeilen bekommen hätten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 08:58:18 +02:00
noroot
eccf02050b fix: .page-wide hebt die 1100px-Breitendeckelung für Tabellen-Seiten auf
.page war für Formulare/Fließtext sinnvoll begrenzt, verschwendete auf
breiten Bildschirmen aber sichtbar Platz bei Tabellen mit vielen Spalten
(Werkzeugkatalog, Register, Freigabe-Regeln, Nutzer-Import-Ergebnis) -
die Tabelle musste horizontal scrollen, obwohl rechts noch Leerraum war.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 08:54:53 +02:00
noroot
8a8295dacd feat: Löschfristen je Datenklasse, Passwort-Zurücksetzen, DPF-Recherche
- Löschfristen (Migration 0020): pro Mandant einstellbar statt fest im
  Regelwerk, da die DSGVO selbst keine festen Fristen nennt (Art. 5
  Abs. 1 lit. e). Jede Firma wird mit risikogestaffelten Vorschlagswerten
  vorbelegt, loeschfrist_max_tage wird jetzt tatsächlich hart gegen
  Werkzeuge gefiltert (schließt rules/OPEN.md Punkt 4).
- Neues internal/mail-Paket (SMTP-Versand + Test-Doppel) und darauf
  aufbauend Passwort-Zurücksetzen (Migration 0019) - bisher nur als
  Absicht in der Rollentabelle genannt, nie gebaut.
- dpf_zertifiziert für alle 20 Katalogeinträge gegen das offizielle
  DPF-Register recherchiert und in der Produktions-DB aktualisiert
  (12 zertifiziert, 5 recherchiert-nicht-gefunden, 3 nicht anwendbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 07:38:12 +02:00
noroot
690660b655 feat: Standard-Genehmiger-Rollen automatisch bei Firmenanlage anlegen
Jede neue Firma (Registrierung + Betreiber-Firmenanlage) bekommt jetzt
automatisch vier leere Genehmiger-Rollen mit erklärender Beschreibung
(Datenschutzbeauftragter, Geschäftsführer, KI-Manager, CISO) - Admin
muss nur noch Personen zuordnen statt bei null anzufangen. Welche
Bedingung welche Rolle tatsächlich auslöst, bleibt weiterhin komplett
konfigurierbar pro Firma (Migration 0018 fügt genehmiger_rolle.beschreibung
als reines Freitext-Orientierungsfeld hinzu, keine feste fachliche
Bindung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:18:49 +02:00
noroot
f729e5ae48 feat: konfigurierbaren Freigabe-Workflow mit Genehmiger-Rollen einführen
Mandanten können jetzt selbst festlegen, wer bei welcher abgeleiteten
Bedingung (Anforderung/Einstufung/Datenklasse) zusätzlich zur
Fachebene-Entscheidung zustimmen muss (z. B. Datenschutzbeauftragter,
Geschäftsführung) - Genehmiger-Rollen sind orthogonal zu den fünf
bestehenden Zugriffsrollen. Alle ausgelösten Rollen müssen zustimmen,
eine Ablehnung kippt kaskadierend den gesamten Antrag. Mandanten ohne
Freigabe-Regeln (aktuell alle) sind unverändert vom alten Direktpfad
betroffen, siehe TestOhneFreigabeRegelnVerhaeltSichWieVorher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:42:28 +02:00
noroot
9e2c7f92ff feat: PageHeader/Filter/Paginierung auf Betreiber-Accounts, Audit-Log, Meine Anträge, Wiedervorlage ausgerollt
Vervollständigt den Rollout aus dem vorigen Schritt: Betreiber-Accounts
und Audit-Log (Betreiber-Bereich) sowie "Meine Anträge" bekommen
.page-header, Freitext-Filter (matchesQuery) und Paginierung.
Wiedervorlage nur .page-header, bewusst ohne Paginierung (natürlich
begrenzte Liste).

Audit-Log holt weiterhin nur die neuesten 1000 Einträge aus der DB
und paginiert im Go-Code darüber (auditLogFetchLimit) - keine echte
DB-Offset-Paginierung, als bekannte Einschränkung dokumentiert.
2026-08-31 19:33:27 +02:00
noroot
4c60603055 feat: PageHeader/Filter/Paginierung auf Register, Nutzerverwaltung, Abteilungen, Posteingang, Betreiber-Dashboard ausgerollt
Register und Fälle-Posteingang bekommen .page-header, Freitext-Filter
und Paginierung; Register zusätzlich .table-responsive statt
.table-scroll. Nutzerverwaltung bekommt Filter+Paginierung (bleibt
Karten-Liste). Abteilungen nur .page-header (bewusst ohne Paginierung,
typischerweise kleine Listen). Betreiber-Dashboard von .admin-kacheln
auf .stat-cards umgestellt.

Neuer gemeinsamer Helfer matchesQuery() in pagination.go für die
Freitext-Filter aller Listen-Seiten.

Dabei einen bereits live gewesenen CSS-Bug gefunden und behoben:
.filter-bar direkt auf einem <form> verlor gegen "form {
flex-direction: column }" (gleiche Spezifität, nur form setzte die
Property), wodurch die Filterleiste senkrecht statt waagerecht
stapelte — betraf die im letzten Schritt ausgelieferte
Mandanten-Werkzeugkatalog-Seite. Jetzt robust via explizitem
flex-direction: row in .filter-bar selbst.
2026-08-31 16:12:41 +02:00
noroot
bc99858c94 feat: enconf-Layout-Patterns übertragen — responsive Card-Tabellen, PageHeader, StatCards
Über die Architect-RAG-Suche (ac_search_code_global) drei UX-Muster
aus dem enconf-Schwesterprojekt (React/Ant Design) identifiziert und
als reines CSS/HTML nachgebaut, da Deklarix bewusst kein SPA ist:

- .table-responsive: Tabellen werden unter 640px zu einer Card-Liste
  statt horizontal zu scrollen (enconfs ProTable-Verhalten), via
  data-label-Attribut je <td>. Angewendet auf beide
  Werkzeugkatalog-Tabellen.
- .page-header: Titel+Unterzeile links, primäre Aktion rechts, statt
  eines nackten <h1> mit Link irgendwo unten.
- .stat-cards: Kennzahl-Kacheln mit farbigem Akzentrand (nur farbig
  bei Wert > 0) statt einfacher Text-Links, auf der Startseite.
2026-08-31 15:55:34 +02:00
noroot
3aecbb446c feat: Filter und Paginierung für den Werkzeugkatalog, Claude Code als Katalogeintrag
Beide Katalog-Ansichten (Betreiber + Mandant) haben jetzt eine
Filterleiste (Freitext über Name/Anbieter/Verarbeitungsländer,
Zweck-Dropdown) und serverseitige Paginierung (20/Seite) über ein
GET-Formular, kein JavaScript. pagination.go ist generisch gehalten
(Go-Generics) für Wiederverwendung in weiteren Listen.

Claude Code als eigener Katalogeintrag ergänzt (nicht identisch mit
Claude for Work: läuft lokal, hat Dateizugriff, lokale Sitzungs-
protokolle mit eigenem Aufbewahrungsrisiko).
2026-08-31 15:09:23 +02:00
noroot
08c7721165 docs: DPF-Recherche als offenen Punkt festhalten 2026-08-31 13:55:58 +02:00
noroot
59e1bcc8f7 feat: Werkzeugkatalog nennt tatsächliche Verarbeitungsländer statt EU/USA/gemischt-Eimer, plus DPF-Feld
Die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere -
der alte verarbeitungsort-Wertebereich (EU/USA/gemischt/on-prem)
verschleierte das. Ersetzt durch verarbeitungslaender TEXT[] mit
echter Weltländerliste im Formular (Mehrfachauswahl, kein JS nötig).
Der harte Filter eu_verarbeitung verlangt jetzt, dass ALLE genannten
Länder EU/EWR sind. Neues Feld dpf_zertifiziert macht die EU-US Data
Privacy Framework-Zertifizierung strukturiert statt nur Fließtext.
Öffnet nebenbei den Weg für Katalogeinträge außerhalb EU/USA (z. B.
DeepSeek), die vorher am alten Wertebereich scheiterten.

Zusätzlich Typografie-Nachbesserung nach Nutzer-Feedback: Formular-
Abschnittsüberschriften (<legend>) saßen zu dicht am Kartenrand, weil
<legend> das Padding des umschließenden <fieldset> ignoriert - jetzt
mit eigenem Padding und größerer Schrift, h2/h3 einheitlich gesetzt.
2026-08-31 13:52:00 +02:00
noroot
2e459f1dd1 docs: Werkzeugkatalog-Stand aktualisieren (19 Einträge, alle 12 Zweck-Kategorien), Schema-Lücke für Drittland-Hosting dokumentieren
Katalog um 9 weitere recherchierte Werkzeuge erweitert (Notion AI,
Grammarly Business, Midjourney, Julius AI, Synthesia, ElevenLabs,
Zendesk AI, Canva Magic Studio, Zapier) — deckt jetzt alle zwölf
Zweck-Kategorien ab. Midjourney bewusst als Negativbeispiel (kein AVV,
keine EU-Residenz, unbegrenzte Speicherung) aufgenommen statt
weggelassen. DeepSeek NICHT angelegt: verarbeitungsort erlaubt nur
EU/USA/gemischt/on-prem, keiner davon bildet China-Hosting korrekt ab
— Wertebereich müsste zuerst erweitert werden.
2026-08-31 13:17:13 +02:00
noroot
d77b19f61f feat: Zweck-Vokabular auf 12 Kategorien erweitert, Werkzeug-Formular im Enterprise-Look überarbeitet
Sechs neue Zweck-Kategorien (Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten)
ergänzen die ursprünglichen sechs — deckt jetzt die tatsächliche
Bandbreite an Einsatzgebieten ab statt nur klassischer Wissensarbeit.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:47:07 +02:00
103 changed files with 7863 additions and 608 deletions

818
CLAUDE.md
View File

@@ -39,8 +39,8 @@ Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
erkennen, siehe `gueltigeZwecke` weiter unten), nie für die Einstufung
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
---
@@ -115,11 +115,46 @@ reiner Anzeige-Screen reicht nicht):
— ein zentraler oder fremder Eintrag ist über diese Route nicht
erreichbar (404).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
`GET /betreiber/accounts/{id}` kann sich der Betreiber über
`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt
die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum
Betreiber" — der Betreiber meldet sich danach mit den eigenen
Zugangsdaten neu an). Jede Nutzung erzeugt einen `audit_log`-Eintrag
(Action `betreiber_login_als_nutzer`); während der gesamten Sitzung
zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist,
im Kontext eines fremden Kontos zu handeln.
**Firmen-CRUD für den Betreiber ist umgesetzt:** `GET/POST
/betreiber/accounts/neu` legt eine Firma samt erstem `admin`-Login an
(dasselbe Ergebnis wie die öffentliche Registrierung, nur vom Betreiber
ausgelöst — z. B. für vertriebsunterstütztes Onboarding oder Testkonten),
`POST /betreiber/accounts/{id}/umbenennen` korrigiert den Firmennamen
(`store.UpdateAccount`). Jede Firmenanlage erzeugt einen
`audit_log`-Eintrag. Bewusst **kein** Löschen — ein Hard-Delete würde
gegen die Fremdschlüssel aus `antrag`/`app_user`/`audit_log` laufen und
Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
noch nicht, siehe unten).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, das
Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche
Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes,
externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**.
Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte
Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum),
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst
(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage
Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der
Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts
durch den Betreiber hängt weiter an dieser (jetzt kleineren)
Subskriptions-Architektur (siehe Offene Punkte: `account.verified`
wurde beim Produktwechsel sogar entfernt).
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
@@ -216,11 +251,151 @@ Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
zentralen Katalog zu verändern.
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsländer, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
nicht in der UI umgesetzt, siehe Offene Punkte).
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
`betreiber_werkzeug_handlers.go`, CSS-Klassen `status-niedrig`/
`-mittel`/`-hoch`, dieselben Farbtoken wie die KI-VO-Risikoampel):
**aktuell** (grün, < 150 Tage), **bald fällig** (gelb, 150180 Tage —
dasselbe 30-Tage-Vorwarnfenster wie bei der Wiedervorlage), **abgelaufen**
(rot, > 180 Tage).
`aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet
"vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte
Zusicherung: sofortige Löschung", z. B. DeepL) unterschieden. Vorher
war die Spalte `NOT NULL DEFAULT 0`, wodurch beide Fälle identisch
aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
"0 Tage".
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
Vokabular statt Freitext: das Formular zeigt Checkboxen statt eines
Freitextfelds (`gueltigeZwecke` in `betreiber_werkzeug_handlers.go`,
serverseitig validiert via `istGueltigerZweck`). Grund: ein künftiger
automatischer Zweckabgleich (Bewertungslogik Schritt 3, weiterhin nicht
gebaut) würde an inkonsistentem Freitext ("Text" statt
"Textgenerierung") scheitern. Am 2026-08-31 von sechs auf zwölf
Kategorien erweitert, nachdem der Nutzer zurecht einwandte, dass sechs
Kategorien die tatsächliche Bandbreite an Einsatzgebieten nicht
abdecken: Textgenerierung, Übersetzung, Code, Bild, Transkription,
Recherche, **Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten**.
Es gibt keinen einheitlichen Branchenstandard für KI-Funktions-
kategorien (regulatorische Taxonomien wie die KI-VO selbst oder OECD/
NIST klassifizieren nach Risiko, nicht nach Funktion; Marktplatz-
Kategorien wie bei G2 sind Marketing, kein Standard) — diese zwölf
sind Deklarix' eigene, bewusst begrenzte Liste für den künftigen
Zweckabgleich, keine allgemeine KI-Taxonomie.
Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
AWS bei DeepL) als eigenes, durchsuchbares Feld sichtbar statt nur als
Freitext in `einschraenkungen` — das ist oft der Grund, warum ein
scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
als Tabelle (Name, Anbieter, Verarbeitungsländer, DPF, AVV, Training aus,
Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
**Filter & Paginierung** (seit 2026-08-31, `pagination.go` +
`werkzeugFilter` in `betreiber_werkzeug_handlers.go`): beide
Katalog-Tabellen haben eine Filterleiste (Freitext über Name/Anbieter/
Verarbeitungsländer, Dropdown über die zwölf Zweck-Kategorien) und
serverseitige Paginierung (20 Einträge/Seite, `page`-Query-Parameter).
Alles über ein einziges GET-Formular, ohne JavaScript — Prev/Next-
Buttons setzen `page` per `<button type="submit" name="page"
value="...">`, damit q/zweck beim Blättern erhalten bleiben. Auf der
Mandanten-Seite (die pro Zeile zusätzlich Sperren/Entsperren-Formulare
hat) sind Filter und Paginierungs-Buttons bewusst in getrennte
`<form>`-Elemente aufgeteilt statt in eines um die ganze Tabelle —
verschachtelte `<form>`-Elemente sind ungültiges HTML, die
Paginierungs-Buttons tragen `q`/`zweck` deshalb als Hidden-Felder
nach. `pagination.go` ist bewusst generisch (Go-Generics,
`paginate[T any]`) für Wiederverwendung in weiteren Listen (Register,
Fälle, ...) — bisher nur auf den Werkzeugkatalog angewendet, das war
die von den beiden am dringendsten wachsende Tabelle.
Der zentrale Katalog ist aktuell mit 20 recherchierten Einträgen
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
mindestens einem Eintrag ab. **Claude Code ist bewusst ein eigener
Eintrag neben Claude for Work**, kein Duplikat: es läuft lokal auf
Entwicklerrechnern mit direktem Zugriff auf Dateien/Codebasen und
speichert Sitzungsprotokolle lokal im Klartext (bis zu 30 Tage,
konfigurierbar) — ein zusätzliches lokales Aufbewahrungsrisiko, das bei
einer reinen Web-Chat-Anwendung wie Claude for Work nicht existiert,
obwohl beide über dieselben Commercial Terms/DPA laufen. Jede
Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
hergibt: Synthesia und Zapier haben `training_standard`/
`training_opt_out` bewusst NICHT gesetzt, weil keine belegte Aussage
zum Trainingsausschluss gefunden wurde (nicht mit "trainiert
nachweislich" verwechseln — es ist ein "wissen wir nicht, deshalb
keine Haken"). Mehrere Einträge sind bewusst mit kritischen
Einschränkungen versehen statt schöngefärbt, das ist die vorgesehene
Funktion des Katalogs: Adobe Firefly (Trainingsausschluss ist nicht
Standard), Otter.ai (Training auf de-identifizierten Aufnahmen,
laufende Rechtsstreitigkeiten), **Midjourney (kein AVV, keine
EU-Datenresidenz, unbegrenzte Speicherung inkl. Training — laut
CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
geeignet, absichtlich als Negativbeispiel im Katalog, nicht
weggelassen)**.
**Schema-Lücke behoben (2026-08-31, Migration 0016):** der grobe
`verarbeitungsort`-Eimer (`EU`/`USA`/`gemischt`/`on-prem`) wurde durch
`verarbeitungslaender TEXT[]` ersetzt — echte Länder statt Buckets.
Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
bietet dafür eine echte Weltländerliste (`weltLaenderliste` in
`laenderliste.go`, `<select multiple>`, Mehrfachauswahl per Strg/Cmd-
Klick — kein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
(kein einzelnes Land benannt)" für Anbieter, die nur eine Region statt
eines Staates zusichern, z. B. Microsofts EU Data Boundary; "On-Premise
(selbst gehostet)"). Der harte Filter `eu_verarbeitung`
(`internal/rules/evaluate.go`, `alleLaenderInEUEWR`) verlangt jetzt,
dass ALLE genannten Länder EU/EWR-Mitgliedstaaten sind — eine leere
Liste gilt als nicht erfüllt, nicht als Freifahrtschein. Zusätzlich neues
Feld `dpf_zertifiziert` (Checkbox "EU-US Data Privacy Framework
zertifiziert"): DPF ist eine schmalere, gerichtlich schon zweimal
gekippte Rechtsgrundlage (Safe Harbor, Privacy Shield) für USA-Transfers,
bisher nur als Fließtext in "Einschränkungen" erwähnt, jetzt strukturiert
und damit künftig filterbar. Alle 19 Katalogeinträge wurden mit den
tatsächlichen, bereits recherchierten Ländern nachgepflegt (z. B.
Mistral → Frankreich, DeepL → Deutschland, Synthesia → Irland, Claude
for Work → USA) statt der pauschalen alten Buckets.
Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
Wertebereich, nicht an einer fachlichen Entscheidung.
**`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
@@ -237,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.
@@ -290,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
@@ -316,6 +494,404 @@ 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.
---
## E-Mail-Vorlagen (2026-09-01, Migration 0022)
Auf Nutzerwunsch: alle vom System versendeten E-Mails sollen editierbar
sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen,
durch den Mandanten selbst; für E-Mails, die die Plattform an
Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter
Standard (`/betreiber/email-vorlagen`, nur Betreiber), gesetzt =
mandantenspezifische Übersteuerung (`/verwaltung/email-vorlagen`, nur
Admin) — jeweils ein eigener Nav-Punkt (nicht als weiterer Button auf
einer bestehenden Seite), mit Liste + eigener Bearbeiten-Seite je Typ
(`GET .../email-vorlagen` Liste, `GET/POST .../email-vorlagen/{typ}`
Bearbeiten) statt eines einzigen, mit allen Formularen gestapelten
Screens — auf ausdrücklichen Nutzerwunsch, da mit der Zeit weitere
Benachrichtigungstypen dazukommen sollen und eine gestapelte Liste dann
unübersichtlich würde. `ResolveEmailVorlage` liefert die eigene
Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
Änderung des Plattform-Standards. `emailVorlageTypen` in
`internal/web/email_vorlage_handlers.go` ist die feste, bekannte Liste
der vom System versendeten E-Mails — aktuell nur
`passwort_zuruecksetzen` (die einzige E-Mail, die es bisher gibt),
bewusst keine generische "beliebige E-Mail anlegen"-UI, da jeder Typ an
eine echte Code-Stelle gebunden ist, die ihn tatsächlich versendet
(Platzhalter wie `{{link}}` sind pro Typ verschieden und müssten sonst
geraten werden).
**Bug beim Live-Verifizieren gefunden und behoben:** `UNIQUE
(account_id, typ)` als einzelner Tabellen-Constraint reicht bei
NULLABLE `account_id` NICHT — SQL behandelt NULL nie als gleich zu
NULL, ein Mandant/Betreiber hätte also bei jedem Speichern eine neue
Plattform-Standard-Zeile statt eines Updates bekommen (genau das ist
beim ersten Testlauf passiert: zwei "Version 1"/"Version 2"-Zeilen
gleichzeitig, `ResolveEmailVorlage` griff die falsche). Fix: zwei
partielle Unique-Indizes (`... WHERE account_id IS NULL` /
`... WHERE account_id IS NOT NULL`) statt eines gemeinsamen Constraints
`UpsertEmailVorlage` braucht dafür zwei unterschiedliche
`ON CONFLICT`-Ziele (SQL erlaubt nur ein Ziel je INSERT-Anweisung), mit
dediziertem Regressionstest (`TestUpsertPlattformStandardAktualisiertStattZuDuplizieren`)
abgesichert. Live per curl mit echtem SMTP-Test-Server erneut
verifiziert: zweimaliges Speichern des Plattform-Standards ergibt eine
Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
Übersteuerung bekommt automatisch den zuletzt gesetzten Plattform-Text.
---
## Row-Level-Security (2026-09-01, Migration 0021)
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
nie beim Tabellenbesitzer ohne `FORCE ROW LEVEL SECURITY` (beides harte,
nicht überschreibbare Postgres-Regeln). Lokal verbindet die Anwendung
als echter Superuser `postgres` (Docker-Testumgebung) — dort hätte
`FORCE` allein nichts bewirkt. Auf dem Produktivserver verbindet sie
dagegen über eine eigene, **nicht-privilegierte** Rolle `deklarix`
(kein Superuser), die zugleich Eigentümerin der Tabellen ist — dort
reicht `FORCE` aus. **Diese Erkenntnis kam erst nach einem
Fehlversuch** (siehe "Incident" weiter unten) — die ursprüngliche
Annahme, auch Produktion verbinde als Superuser, war falsch und beruhte
auf einer Prüfung der falschen Rolle. Migration 0021 deckt seit der
Korrektur beide Fälle ab: `FORCE ROW LEVEL SECURITY` auf jeder Tabelle
(reicht für Produktion), plus optional eine neue, eingeschränkte Rolle
**`deklarix_app`** (kein Superuser, kein Tabellenbesitzer,
`NOBYPASSRLS`, zunächst `NOLOGIN`) für Umgebungen mit einer
Superuser-Verbindung wie lokal.
**Architektur:** `internal/store/tenant_scope.go``Store.db(ctx)`
liefert entweder die aktive Transaktion (falls `WithTenantScope` sie
gesetzt hat) oder den Pool direkt; alle ~69 vorherigen `s.Pool.Query/
QueryRow/Exec`-Aufrufe wurden mechanisch auf `s.db(ctx).…` umgestellt.
`WithTenantScope(ctx, accountID, isBetreiber, fn)` öffnet eine
Transaktion, setzt zwei Sitzungsvariablen per `set_config(..., true)`
(Parameterbindung, kein String-Interpolation — SQL-Injection-frei,
automatisch auf die Transaktion begrenzt) — `app.account_id` und
`app.is_betreiber` — und committet am Ende. `SetTenantScope` erlaubt,
die Variable **mitten in einer laufenden** Transaktion nachzutragen
(nötig bei `handleRegister`: die `account_id` ist erst nach
`CreateAccount` bekannt). Jede der fünf `require*`-Middlewares
(`requirePage/-API/-Admin/-Fachebene/-Betreiber`) umschließt die
komplette Handler-Ausführung jetzt mit `withTenantScope` — jeder
Request läuft dadurch automatisch in genau einer Transaktion, nicht nur
die Store-Aufrufe, die ein Handler zufällig selbst noch mal prüft.
**Nebeneffekt (bewusst, positiv):** ein Handler mit mehreren
Schreibzugriffen ist jetzt atomar — schlägt ein späterer Schritt fehl,
werden frühere Schritte desselben Requests zurückgerollt (vorher: keine
Transaktion, ein halb fehlgeschlagener Handler konnte einen verwaisten
Account ohne Nutzer hinterlassen).
**Geschützte Tabellen** (`ENABLE` + `FORCE ROW LEVEL SECURITY` + Policy
`tenant_isolation`): `antrag`, `registereintrag`, `abteilung`,
`werkzeug_sperre`, `genehmiger_rolle`, `freigabe_regel`,
`loeschfrist_einstellung` (direkte `account_id`-Prüfung), `werkzeug`
(nullable — `NULL` = zentraler Katalog, für alle lesbar, nur Betreiber
darf `NULL`-Zeilen schreiben), `bewertung`/`entscheidung`/
`freigabeschritt` (kein eigenes `account_id`, Policy prüft
`antrag_id IN (SELECT id FROM antrag)` — erbt automatisch dieselbe
Grenze wie `antrag`, da diese Unterabfrage in derselben Sitzung
läuft), `nutzer_genehmiger_rolle` (analog über `genehmiger_rolle_id`).
**Bewusst OHNE RLS** (dokumentiert in der Migration, kein Versehen):
`account` (keine `account_id`-Spalte, muss bei Registrierung
uneingeschränkt INSERT erlauben, bevor die ID bekannt ist), `app_user`
(Login/Passwort-Reset suchen per E-Mail über alle Mandanten hinweg —
die Ziel-`account_id` ist zu dem Zeitpunkt noch nicht bekannt),
`session`/`password_reset_token` (werden nur über einen
kryptographisch zufälligen, praktisch unerratbaren Token gesucht, kein
sequentiell erratbarer Schlüssel), `audit_log` (plattformweites
Protokoll ohne eigene `account_id`, wird ausschließlich vom Betreiber
gelesen, der ohnehin alle Mandanten sieht).
**Live end-to-end verifiziert** (nicht nur Unit-Tests): Rohes SQL
gegen `deklarix_app` direkt per `psql` (Isolation, Betreiber-Bypass,
Fail-Closed ohne Kontext, INSERT-Schutz gegen fremde `account_id`
alle vier Fälle bestätigt), danach derselbe Beweis noch mal über den
echten laufenden HTTP-Server mit `DATABASE_URL_APP` auf `deklarix_app`
gesetzt: zwei Firmen registriert, Abteilung/Antrag/Bewertung einer
Firma über die andere nicht erreichbar, zentraler Werkzeugkatalog
(NULL-`account_id`) für beide sichtbar. Dabei einen echten Bug gefunden
und behoben: `handleRegister`s `startSession`-Aufruf nutzte noch
`r.Context()` statt der lokalen, transaktionsbehafteten `ctx`-Variable
— die Session-INSERT lief dadurch auf einer anderen (Pool-)Verbindung
als der gerade erst angelegte, noch nicht committete Nutzer, was einen
Fremdschlüssel-Konflikt auslöste und die gesamte Registrierung
zurückrollte. Zwei dedizierte Tests
(`internal/store/tenant_scope_test.go`, `TestRLSAbteilungIsoliertZwischenMandanten`,
`TestRLSVerhindertInsertFuerFremdenAccount`) laufen gegen die echte
eingeschränkte Rolle und überspringen sich selbst sauber, wenn
`DATABASE_URL_APP` nicht gesetzt ist (analog zum bestehenden
`DATABASE_URL`-Skip-Muster).
**Incident 2026-09-01 (~3 Minuten Downtime) und Korrektur.** Die erste
Fassung der Migration ging fälschlich davon aus, dass die Anwendung
überall als Postgres-**Superuser** `postgres` verbindet (das hatte ich
nur lokal und via `sudo -u postgres psql` auf dem Server geprüft — das
ist aber ein SSH/OS-Login-Check, nicht die tatsächliche
`DATABASE_URL`-Rolle der Anwendung). Tatsächlich verbindet Produktion
über eine eigene, **nicht-privilegierte** Rolle `deklarix` (kein
Superuser, kein `CREATEROLE`), die zugleich Eigentümerin aller Tabellen
ist. Die Migration versuchte dort `CREATE ROLE deklarix_app`
auszuführen, scheiterte mit "permission denied to create role", blieb
im `dirty`-Zustand hängen und der Dienst crash-loopte beim Start
(09:2709:30 Uhr). Behoben durch: `schema_migrations` manuell auf
Version 20 zurückgesetzt, Paket auf v0.37.0 zurückgestuft, Dienst
stabilisiert — kein Datenverlust, Postgres hatte die fehlgeschlagene
Migration als DDL-Transaktion bereits sauber selbst zurückgerollt, nur
golang-migrates eigene Versions-Buchführung musste von Hand korrigiert
werden.
**Korrigierte, robustere Migration:** das Anlegen von `deklarix_app` ist
jetzt an eine Prüfung gekoppelt (`SELECT ... WHERE rolname = current_user
AND rolcreaterole`) und wird bei fehlendem `CREATEROLE` übersprungen
(`RAISE NOTICE`, kein Fehler) statt die ganze Migration scheitern zu
lassen. Zusätzlich bekommt jede Tabelle jetzt **`FORCE ROW LEVEL
SECURITY`** (vorher nur `ENABLE`) — das bindet auch den **Tabellen-
besitzer** an die Policies, sofern er kein Superuser ist. Damit deckt
eine einzige Migration beide Fälle ab:
- **Produktion** (`deklarix`, Tabellenbesitzer, kein Superuser): `FORCE`
allein reicht bereits aus. **Kein manueller Schritt nötig** — nach dem
Deploy dieser Migration ist RLS dort sofort aktiv.
- **Lokales Docker-Postgres** (`postgres`-Superuser, für den `FORCE`
wirkungslos bleibt): `deklarix_app` wird zusätzlich angelegt, für
lokale Tests weiterhin per `DATABASE_URL_APP` nutzbar.
**Erneut end-to-end verifiziert nach der Korrektur**, diesmal
zusätzlich produktionsgetreu: eine zweite, temporäre lokale Rolle
(`deklarix_sim`, kein Superuser, kein `CREATEROLE`, Eigentümerin einer
frischen Test-Datenbank — exakt Produktions-Rechtemodell) durchlief die
komplette Migrationskette 00010021 fehlerfrei, **und** die Isolation
griff nachweislich auch für sie als Tabellenbesitzerin (Kontext A sah
ausschließlich Zeile A, trotz voller Tabellen-Eigentümerschaft). Vorher
war nur der `deklarix_app`-Pfad (Nicht-Eigentümer-Rolle) bewiesen, nicht
der tatsächliche Produktions-Pfad (Eigentümer-Rolle + FORCE) — genau die
Lücke, die den Incident verursachte.
**Lehre für künftige Prüfungen dieser Art:** "welche DB-Rolle verwendet
die Anwendung" per `sudo -u postgres psql` zu beantworten prüft die
falsche Sache — maßgeblich ist ausschließlich die Rolle **in der
tatsächlichen `DATABASE_URL`** (hier: `cat /etc/deklarix/deklarix.env`
bzw. `SELECT rolname, rolsuper, rolcreaterole FROM pg_roles WHERE
rolname = 'deklarix'`, nicht `current_user` über einen andere Anmeldung).
---
## 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
@@ -385,7 +961,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
@@ -526,12 +1232,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).
---
@@ -734,25 +1450,31 @@ journalctl -u deklarix -f
## Offene Punkte
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
bleiben, aber die Ableitung kann sich noch ändern.
- ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
nötig.
- ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
(`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
eingeschlossen.
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
(siehe `rules/OPEN.md`, Punkt 3).
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
— die Frontend-Spezifikation fordert das explizit ("nicht in der
Anwendungslogik"), aktuell ist Isolation ausschließlich
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
entstehen, die sonst nachträglich migriert werden müssten.
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
- ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
**bestätigt**, `intern` bleibt die konservative Standardannahme.
- **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
(`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
`kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
Rückmeldung dazu bisher eingeholt.
- ~~Löschfristen je Datenklasse~~ — **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)~~
— **Code/Migration/Tests erledigt 2026-09-01, Produktivbetrieb noch
ausstehend** (siehe „Row-Level-Security" weiter oben für den vollen
Stand und was auf dem Produktivserver noch manuell nachzuziehen ist).
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
@@ -767,15 +1489,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.

View File

@@ -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"
@@ -21,7 +22,19 @@ func main() {
log.Fatalf("migrate: %v", err)
}
db, err := store.Open(context.Background(), databaseURL)
// Migrationen laufen immer über DATABASE_URL (braucht DDL-Rechte,
// z. B. CREATE TABLE/ALTER TABLE). Die laufende Anwendung verbindet
// sich dagegen möglichst über die eingeschränkte Row-Level-Security-
// Rolle "deklarix_app" (siehe Migration 0021) — DATABASE_URL_APP,
// falls gesetzt. Ohne DATABASE_URL_APP fällt sie auf DATABASE_URL
// zurück (bisheriges Verhalten, Postgres-Superuser umgeht RLS-
// Policies dann vollständig — kein Fehler, aber auch keine
// zusätzliche Isolation auf DB-Ebene, siehe CLAUDE.md).
appDatabaseURL := os.Getenv("DATABASE_URL_APP")
if appDatabaseURL == "" {
appDatabaseURL = databaseURL
}
db, err := store.Open(context.Background(), appDatabaseURL)
if err != nil {
log.Fatalf("open store: %v", err)
}
@@ -51,9 +64,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
View 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
}

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ type Abteilung struct {
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
var a Abteilung
err := s.Pool.QueryRow(ctx, `
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
RETURNING id, account_id, name, created_at
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
@@ -33,7 +33,7 @@ func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Ab
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
// alphabetisch — als Auswahlliste für den Fragebogen.
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, name, created_at FROM abteilung
WHERE account_id = $1 ORDER BY name
`, accountID)
@@ -59,7 +59,7 @@ func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string)
// GetAbteilung liest eine Abteilung anhand ihrer ID.
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
var a Abteilung
err := s.Pool.QueryRow(ctx, `
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
@@ -74,7 +74,7 @@ func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error)
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
// angelegt).
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete abteilung: %w", err)
}

View File

@@ -33,7 +33,7 @@ func scanAccount(row interface {
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
RETURNING `+accountColumns,
name,
@@ -45,9 +45,23 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
return a, nil
}
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
// Betreiber-gestützten Anlage korrigieren).
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: update account: %w", err)
}
return a, nil
}
// GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
@@ -63,7 +77,7 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
// oder bereits erneuertem (damit ungültig gewordenem) Token.
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
@@ -78,7 +92,7 @@ func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (A
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
tag, err := s.db(ctx).Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
if err != nil {
return fmt.Errorf("store: regenerate einladung token: %w", err)
}
@@ -91,7 +105,7 @@ func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToke
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}

View File

@@ -46,7 +46,7 @@ func scanAntrag(row interface {
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
VALUES ($1, $2, $3, $4)
RETURNING `+antragColumns,
@@ -62,7 +62,7 @@ func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID str
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
row := s.db(ctx).QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
a, err := scanAntrag(row)
if errors.Is(err, pgx.ErrNoRows) {
return Antrag{}, ErrNotFound
@@ -78,7 +78,7 @@ func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
// Status hier nicht).
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
UPDATE antrag SET
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
WHERE id = $1
@@ -99,7 +99,7 @@ func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung,
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
tag, err := s.db(ctx).Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
if err != nil {
return fmt.Errorf("store: set antrag status: %w", err)
}
@@ -114,7 +114,7 @@ func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
`, erstellerUserID)
if err != nil {
@@ -139,7 +139,7 @@ func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string)
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
// zuerst.
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
`, accountID)
if err != nil {

View File

@@ -22,7 +22,7 @@ type AuditEntry struct {
// CreateAuditEntry protokolliert eine Admin-Aktion.
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
var e AuditEntry
err := s.Pool.QueryRow(ctx, `
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
@@ -37,7 +37,7 @@ func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targe
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
FROM audit_log ORDER BY created_at DESC LIMIT $1
`, limit)

View File

@@ -30,6 +30,39 @@ func TestAccountCRUD(t *testing.T) {
}
}
func TestUpdateAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
if err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
if updated.Name != "Neuer Name GmbH" {
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Neuer Name GmbH" {
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
}
}
func TestUpdateAccountNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
@@ -191,6 +224,9 @@ func TestSessionCRUD(t *testing.T) {
if got.UserID != user.ID {
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
}
if got.ImpersonatedByUserID != nil {
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
}
if err := s.DeleteSession(ctx, sess.Token); err != nil {
t.Fatalf("DeleteSession: %v", err)
@@ -207,3 +243,38 @@ func TestGetSessionNotFound(t *testing.T) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestCreateImpersonatedSession(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
kundenAcc := testAccountID(t, s)
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
if err != nil {
t.Fatalf("CreateUser (Kunde): %v", err)
}
betreiberAcc := testAccountID(t, s)
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser (Betreiber): %v", err)
}
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
if err != nil {
t.Fatalf("CreateImpersonatedSession: %v", err)
}
if sess.UserID != kundenNutzer.ID {
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
}
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
}
got, err := s.GetSession(ctx, sess.Token)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
}
}

View File

@@ -104,7 +104,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
}
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO bewertung (
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
@@ -124,7 +124,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
// existiert.
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
SELECT `+bewertungColumns+`
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
`, antragID)

View File

@@ -0,0 +1,116 @@
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type EmailVorlage struct {
ID string
AccountID *string
Typ string
Betreff string
Text string
UpdatedAt time.Time
}
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
// accountID nil schreibt den plattformweiten Standard (nur für den
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
//
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
var v EmailVorlage
var err error
if accountID == nil {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES (NULL, $1, $2, $3)
ON CONFLICT (typ) WHERE account_id IS NULL
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
} else {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
}
return v, nil
}
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
// accountID nil sucht den plattformweiten Standard.
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
}
return v, nil
}
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
// Plattform-Standard wird per Migration angelegt).
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
ORDER BY account_id NULLS LAST
LIMIT 1
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
}
return v, nil
}
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
// wieder auf den plattformweiten Standard zurück), beim Betreiber
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
tag, err := s.db(ctx).Exec(ctx, `
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ)
if err != nil {
return fmt.Errorf("store: delete email vorlage: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,117 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
}
if v.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
}
if v.Betreff == "" || v.Text == "" {
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
}
}
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
}
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
}
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
}
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
t.Fatalf("DeleteEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
}
}
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
s := openTestStore(t)
ctx := context.Background()
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (1): %v", err)
}
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (2): %v", err)
}
if first.ID != second.ID {
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
}
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage: %v", err)
}
if got.Betreff != "Zweiter Betreff" {
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
}
}
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
t.Fatalf("UpsertEmailVorlage A: %v", err)
}
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage B: %v", err)
}
if resolvedB.Betreff == "Vorlage A" {
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
}
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
}
}

View File

@@ -78,7 +78,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E
}
}
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO entscheidung (
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -96,7 +96,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
// Entscheidung existiert.
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
SELECT `+entscheidungColumns+`
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
`, antragID)
@@ -118,7 +118,7 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
// zum Mandanten läuft über den zugehörigen antrag.
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
FROM entscheidung e

388
internal/store/freigabe.go Normal file
View 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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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
}

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

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

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

View File

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

View File

@@ -0,0 +1,8 @@
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
-- — die authenticate-Middleware liest das, um in der Nav einen
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
-- er/sie gerade im Kontext eines fremden Kontos handelt.
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);

View File

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

View File

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

View File

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

View File

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

View 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;

View 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'));

View File

@@ -0,0 +1 @@
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;

View File

@@ -0,0 +1 @@
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1 @@
DROP TABLE password_reset_token;

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

View File

@@ -0,0 +1 @@
DROP TABLE loeschfrist_einstellung;

View File

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

View File

@@ -0,0 +1,58 @@
ALTER TABLE antrag NO FORCE ROW LEVEL SECURITY;
ALTER TABLE antrag DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON antrag;
ALTER TABLE registereintrag NO FORCE ROW LEVEL SECURITY;
ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON registereintrag;
ALTER TABLE abteilung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON abteilung;
ALTER TABLE werkzeug_sperre NO FORCE ROW LEVEL SECURITY;
ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre;
ALTER TABLE genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle;
ALTER TABLE freigabe_regel NO FORCE ROW LEVEL SECURITY;
ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel;
ALTER TABLE loeschfrist_einstellung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung;
ALTER TABLE werkzeug NO FORCE ROW LEVEL SECURITY;
ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON werkzeug;
ALTER TABLE bewertung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON bewertung;
ALTER TABLE entscheidung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON entscheidung;
ALTER TABLE freigabeschritt NO FORCE ROW LEVEL SECURITY;
ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt;
ALTER TABLE nutzer_genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
ALTER TABLE nutzer_genehmiger_rolle DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON nutzer_genehmiger_rolle;
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
EXECUTE 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app';
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app';
EXECUTE 'REVOKE USAGE ON SCHEMA public FROM deklarix_app';
DROP ROLE deklarix_app;
END IF;
END
$$;

View File

@@ -0,0 +1,187 @@
-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security).
--
-- WICHTIG, per Incident am 2026-09-01 gelernt: RLS-Policies wirken NIE
-- bei Postgres-Superusern, und NIE beim Tabellenbesitzer ohne FORCE ROW
-- LEVEL SECURITY. Welcher Fall zutrifft, hängt von der Umgebung ab:
-- - Produktion verbindet als eigene, nicht-privilegierte Rolle (z. B.
-- "deklarix"), die zugleich Eigentümerin der Tabellen ist (sie hat
-- sie über die Migrationen selbst angelegt) — für sie reicht FORCE
-- ROW LEVEL SECURITY völlig aus, keine weitere Rolle nötig.
-- - Manche Entwicklungs-/Testumgebungen verbinden dagegen als
-- echter Postgres-Superuser (z. B. lokales Docker-Postgres mit
-- "postgres") — für den wirkt FORCE nicht (Superuser sind davon
-- laut Postgres-Dokumentation ausdrücklich ausgenommen). Dort kann
-- zusätzlich eine eingeschränkte Rolle "deklarix_app" angelegt
-- werden, für die die Policies unabhängig von FORCE gelten.
--
-- Diese Migration deckt BEIDE Fälle ab, ohne bei fehlendem CREATEROLE
-- fehlzuschlagen (das brachte den Dienst am 2026-09-01 für ~3 Minuten
-- zum Absturz, siehe CLAUDE.md) — das Anlegen von "deklarix_app" ist
-- rein optional und wird übersprungen, wenn die aktuelle Rolle dafür
-- keine Berechtigung hat.
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND rolcreaterole) THEN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
END IF;
EXECUTE 'GRANT USAGE ON SCHEMA public TO deklarix_app';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app';
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app';
ELSE
RAISE NOTICE 'Rolle % hat kein CREATEROLE — deklarix_app wird übersprungen, FORCE ROW LEVEL SECURITY schützt stattdessen direkt die bestehende (Tabellenbesitzer-)Rolle.', current_user;
END IF;
END
$$;
-- Login-Fähigkeit + Passwort für deklarix_app (falls angelegt) werden
-- einmalig manuell je Umgebung gesetzt, NIE in einer versionierten
-- Migration (Klartext-Secret gehört nicht ins Repo):
-- ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<generiertes Secret>';
-- Danach optional DATABASE_URL_APP in der jeweiligen deklarix.env
-- eintragen. Für Umgebungen, in denen die Anwendung bereits als
-- Tabellenbesitzer (nicht-Superuser) verbindet, ist das NICHT nötig —
-- FORCE ROW LEVEL SECURITY unten reicht dort aus.
-- ─── Tabellen MIT direkter account_id-Spalte ───────────────────────────
-- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE
-- (USING) und INSERT/UPDATE (WITH CHECK) ab. NULLIF(..., '')::uuid
-- verhindert einen harten Cast-Fehler, wenn app.account_id nie gesetzt
-- oder auf '' steht (z. B. vor der eigentlichen Anmeldung) — die
-- Bedingung wird dann einfach UNKNOWN/false statt eines SQL-Fehlers.
CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE antrag ENABLE ROW LEVEL SECURITY;
ALTER TABLE antrag FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY;
ALTER TABLE registereintrag FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY;
ALTER TABLE abteilung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY;
ALTER TABLE werkzeug_sperre FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY;
ALTER TABLE genehmiger_rolle FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY;
ALTER TABLE freigabe_regel FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY;
ALTER TABLE loeschfrist_einstellung FORCE ROW LEVEL SECURITY;
-- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ──────────
-- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der
-- Betreiber darf einen zentralen (NULL) Eintrag anlegen/ändern, ein
-- Mandant nur seine eigenen — sonst könnte ein Mandant über einen
-- vergessenen Anwendungscheck einen zentralen Katalogeintrag verändern.
CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
account_id IS NULL
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
);
ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY;
ALTER TABLE werkzeug FORCE ROW LEVEL SECURITY;
-- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ───
-- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine
-- Unterabfrage gegen sie erbt in derselben Sitzung automatisch dieselbe
-- Mandantengrenze, ohne die Bedingung hier zu duplizieren.
CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
) WITH CHECK (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY;
ALTER TABLE bewertung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
) WITH CHECK (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY;
ALTER TABLE entscheidung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
) WITH CHECK (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY;
ALTER TABLE freigabeschritt FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
) WITH CHECK (
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
);
ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY;
ALTER TABLE nutzer_genehmiger_rolle FORCE ROW LEVEL SECURITY;
-- ─── Bewusst OHNE RLS ───────────────────────────────────────────────────
-- account: hat keine account_id-Spalte (ist selbst der Mandant) und
-- muss bei der Registrierung uneingeschränkt INSERT erlauben, bevor
-- die neue ID überhaupt bekannt ist.
-- app_user: Login/Passwort-Zurücksetzen suchen per E-Mail über ALLE
-- Mandanten hinweg (die Ziel-account_id ist zu diesem Zeitpunkt noch
-- nicht bekannt) — kein Datenleck, da E-Mail-Adressen exakt und nicht
-- in Bulk abgefragt werden, kein sequentiell erratbarer Schlüssel.
-- session, password_reset_token: werden ausschließlich über einen
-- kryptographisch zufälligen, praktisch unerratbaren Token gesucht,
-- nicht über eine sequentielle ID — dieselbe Begründung wie app_user.
-- audit_log: plattformweites Protokoll, wird ausschließlich vom
-- Betreiber (Ebene 5, sieht ohnehin alle Mandanten) gelesen, hat keine
-- eigene account_id-Spalte.

View File

@@ -0,0 +1,4 @@
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
DROP TABLE email_vorlage;

View File

@@ -0,0 +1,55 @@
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
-- internal/web/email_vorlage_handlers.go).
CREATE TABLE email_vorlage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES account(id),
typ TEXT NOT NULL,
betreff TEXT NOT NULL,
text TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
-- Plattformweiter Standard für die einzige aktuell existierende
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
NULL,
'passwort_zuruecksetzen',
'Deklarix — Passwort zurücksetzen',
'Hallo,' || E'\n\n' ||
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
'{{link}}' || E'\n\n' ||
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
);
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
-- account_id-Zeile anlegen/ändern.
CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
account_id IS NULL
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
);
ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;

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

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

View File

@@ -64,7 +64,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp
if in.Auflagen == nil {
in.Auflagen = []string{}
}
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO registereintrag (
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
@@ -83,7 +83,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
// Mandanten, neueste zuerst.
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
`, accountID)
if err != nil {

View File

@@ -12,38 +12,70 @@ import (
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
// eine Session wird immer über ihren Token nachgeschlagen.
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
// statt durch den regulären Login des Nutzers selbst.
type Session struct {
Token string
UserID string
ExpiresAt time.Time
CreatedAt time.Time
Token string
UserID string
ImpersonatedByUserID *string
ExpiresAt time.Time
CreatedAt time.Time
}
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
func scanSession(row interface {
Scan(dest ...any) error
}) (Session, error) {
var sess Session
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
return sess, err
}
// CreateSession speichert eine neue Sitzung. token muss bereits ein
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO session (token, user_id, expires_at)
VALUES ($1, $2, $3)
RETURNING token, user_id, expires_at, created_at
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
RETURNING `+sessionColumns,
token, userID, expiresAt,
)
sess, err := scanSession(row)
if err != nil {
return Session{}, fmt.Errorf("store: create session: %w", err)
}
return sess, nil
}
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING `+sessionColumns,
token, userID, impersonatedByUserID, expiresAt,
)
sess, err := scanSession(row)
if err != nil {
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
}
return sess, nil
}
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
sess, err := scanSession(row)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
@@ -55,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
// DeleteSession beendet eine Sitzung (Logout).
func (s *Store) DeleteSession(ctx context.Context, token string) error {
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
if _, err := s.db(ctx).Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
return fmt.Errorf("store: delete session: %w", err)
}
return nil

View File

@@ -0,0 +1,104 @@
// Row-Level-Security-Unterstützung. Isolation zwischen Mandanten lief
// bisher ausschließlich anwendungsseitig (Handler vergleichen AccountID,
// siehe CLAUDE.md) — das ist die einzige Stelle, die einen vergessenen
// WHERE-account_id-Filter in einer neuen Store-Methode nicht auffängt.
// WithTenantScope öffnet für die Dauer eines Requests eine Transaktion
// und setzt zwei Postgres-Sitzungsvariablen (SET LOCAL, per set_config
// mit Parameterbindung statt String-Interpolation — SQL-Injection-frei
// und automatisch auf die Transaktion begrenzt, kein manuelles Zurück-
// setzen nötig):
//
// - app.account_id — der Mandant, für den dieser Request angemeldet ist
// - app.is_betreiber — "true" für Ebene-5-Zugriff (sieht alle Mandanten)
//
// Migration 0021 aktiviert FORCE ROW LEVEL SECURITY auf den Tabellen mit
// echten Mandanten-Geschäftsdaten und legt Policies an, die genau diese
// beiden Variablen auswerten. Bewusst NICHT auf account/app_user/
// session/password_reset_token (siehe Migration 0021 für die Begründung
// — diese vier brauchen unmandantierte Lookups, z. B. Login per E-Mail).
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// pgxIface wird sowohl von *pgxpool.Pool als auch von pgx.Tx erfüllt —
// db(ctx) kann so transparent zwischen "kein Tenant-Kontext" (Pool,
// z. B. in Store-Tests ohne WithTenantScope) und "innerhalb eines
// Requests" (Tx mit gesetzten Sitzungsvariablen) wählen, ohne dass jede
// einzelne Store-Methode das selbst unterscheiden müsste.
type pgxIface interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
type tenantTxKey struct{}
// db liefert die für ctx passende Ausführungsschnittstelle: die aktive
// Transaktion, falls WithTenantScope sie gesetzt hat, sonst den Pool
// direkt (z. B. für Store-Tests, die ohne Tenant-Kontext laufen — diese
// Tabellen sind dann nicht durch RLS geschützt, was für White-Box-Tests
// der Store-Logik selbst unkritisch ist, siehe dedizierte RLS-Tests in
// tenant_scope_test.go für den tatsächlichen Isolationsnachweis).
func (s *Store) db(ctx context.Context) pgxIface {
if tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx); ok {
return tx
}
return s.Pool
}
// WithTenantScope führt fn in einer Transaktion aus, die die Sitzungs-
// variablen für RLS setzt. accountID kann leer sein (z. B. während der
// Registrierung, bevor der neue Account existiert) — SetTenantScope
// erlaubt, die Variable mitten in derselben Transaktion nachträglich zu
// setzen, sobald die ID bekannt ist.
func (s *Store) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin tenant scope: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op nach erfolgreichem Commit
scopedCtx := context.WithValue(ctx, tenantTxKey{}, tx)
if err := setTenantSessionVars(scopedCtx, tx, accountID, isBetreiber); err != nil {
return err
}
if err := fn(scopedCtx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit tenant scope: %w", err)
}
return nil
}
// SetTenantScope aktualisiert die Sitzungsvariablen innerhalb einer
// bereits laufenden WithTenantScope-Transaktion — nötig, wenn eine neue
// Firma erst mitten im Request entsteht (die account_id ist vorher
// nicht bekannt, siehe handleRegister/handleBetreiberAccountCreate).
// Ruft man es außerhalb von WithTenantScope auf, ist es ein No-op ohne
// Effekt (kein Tx im Context) — daher immer den Rückgabewert prüfen,
// falls das je außerhalb eines Handlers genutzt wird.
func (s *Store) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx)
if !ok {
return fmt.Errorf("store: SetTenantScope außerhalb von WithTenantScope aufgerufen")
}
return setTenantSessionVars(ctx, tx, accountID, isBetreiber)
}
func setTenantSessionVars(ctx context.Context, tx pgx.Tx, accountID string, isBetreiber bool) error {
betreiberFlag := "false"
if isBetreiber {
betreiberFlag = "true"
}
if _, err := tx.Exec(ctx, `SELECT set_config('app.account_id', $1, true), set_config('app.is_betreiber', $2, true)`, accountID, betreiberFlag); err != nil {
return fmt.Errorf("store: set tenant scope: %w", err)
}
return nil
}

View File

@@ -0,0 +1,152 @@
// Beweis, dass Row-Level-Security tatsächlich greift — nicht nur, dass
// die Store-Methoden mit einer Transaktion statt dem Pool funktionieren
// (das würde auch mit einer Superuser-Verbindung "bestehen", ohne dass
// RLS irgendetwas tut, siehe CLAUDE.md/Migration 0021: Superuser
// umgehen RLS-Policies immer). Diese Tests laufen deshalb NICHT gegen
// DATABASE_URL (Superuser, für Migrationen), sondern gegen
// DATABASE_URL_APP — die eingeschränkte Rolle "deklarix_app", für die
// die Policies tatsächlich wirken. Ohne DATABASE_URL_APP werden sie
// übersprungen (die Rolle existiert erst nach manuellem Passwort-Setup,
// siehe Migration 0021 und CLAUDE.md).
package store_test
import (
"context"
"errors"
"os"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
// openRestrictedTestStore öffnet eine zweite Verbindung über
// DATABASE_URL_APP (die eingeschränkte Rolle) — Migrationen und
// Fixture-Aufbau laufen weiterhin über die normale, privilegierte
// openTestStore-Verbindung.
func openRestrictedTestStore(t *testing.T) *store.Store {
t.Helper()
url := os.Getenv("DATABASE_URL_APP")
if url == "" {
t.Skip("DATABASE_URL_APP nicht gesetzt, überspringe RLS-Test (siehe Migration 0021)")
}
s, err := store.Open(context.Background(), url)
if err != nil {
t.Fatalf("Open (restricted): %v", err)
}
t.Cleanup(s.Close)
return s
}
func TestRLSAbteilungIsoliertZwischenMandanten(t *testing.T) {
privileged := openTestStore(t)
restricted := openRestrictedTestStore(t)
ctx := context.Background()
accA := testAccountID(t, privileged)
accB := testAccountID(t, privileged)
abtA, err := privileged.CreateAbteilung(ctx, accA, "Abteilung-A")
if err != nil {
t.Fatalf("CreateAbteilung A: %v", err)
}
if _, err := privileged.CreateAbteilung(ctx, accB, "Abteilung-B"); err != nil {
t.Fatalf("CreateAbteilung B: %v", err)
}
// Mit Tenant-Kontext A: nur die eigene Abteilung ist sichtbar, auch
// wenn wir explizit nach IDs von B fragen würden — hier geprüft über
// die Listen-Methode, die auf accA gefiltert nach RLS zusätzlich
// noch mal (redundant) accountID=accA übergibt; der Beweis liegt in
// TestRLSVerweigertFremdenAccountAuchBeiFalscherAccountID unten, wo
// die Anwendungsschicht bewusst "falsch" fragt.
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
liste, err := restricted.ListAbteilungenForAccount(scoped, accA)
if err != nil {
return err
}
if len(liste) != 1 || liste[0].Name != "Abteilung-A" {
t.Fatalf("liste = %+v, want genau [Abteilung-A]", liste)
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope A: %v", err)
}
// Der eigentliche RLS-Beweis: im Tenant-Kontext A nach ListAbteilungenForAccount(B)
// fragen — ein Programmierfehler, der die AccountID nicht prüft, wäre
// ohne RLS ein echtes Datenleck. Mit RLS liefert die DB trotzdem 0 Zeilen,
// weil die Sitzungsvariable (Kontext A) nicht zu den B-Zeilen passt.
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
liste, err := restricted.ListAbteilungenForAccount(scoped, accB)
if err != nil {
return err
}
if len(liste) != 0 {
t.Fatalf("RLS-LECK: Kontext A sieht %d Zeilen von Account B, want 0", len(liste))
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope A->B: %v", err)
}
// GetAbteilung(abtA.ID) im Kontext B muss ErrNotFound liefern — RLS
// versteckt die fremde Zeile, unabhängig davon, ob die Anwendung die
// AccountID selbst vergleicht.
err = restricted.WithTenantScope(ctx, accB, false, func(scoped context.Context) error {
_, err := restricted.GetAbteilung(scoped, abtA.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("RLS-LECK: GetAbteilung(A) im Kontext B err=%v, want ErrNotFound", err)
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope B: %v", err)
}
// Betreiber-Kontext sieht beide.
err = restricted.WithTenantScope(ctx, "", true, func(scoped context.Context) error {
listeA, err := restricted.ListAbteilungenForAccount(scoped, accA)
if err != nil {
return err
}
listeB, err := restricted.ListAbteilungenForAccount(scoped, accB)
if err != nil {
return err
}
if len(listeA) != 1 || len(listeB) != 1 {
t.Fatalf("Betreiber-Kontext: listeA=%+v listeB=%+v, want je 1", listeA, listeB)
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope Betreiber: %v", err)
}
// Ohne jeden Tenant-Kontext (direkter Pool-Zugriff, keine Transaktion,
// keine Sitzungsvariable gesetzt): fail closed, 0 Zeilen — nicht "alle".
liste, err := restricted.ListAbteilungenForAccount(ctx, accA)
if err != nil {
t.Fatalf("ListAbteilungenForAccount ohne Kontext: %v", err)
}
if len(liste) != 0 {
t.Fatalf("RLS-LECK: ohne Tenant-Kontext sichtbar: %+v, want 0 Zeilen (fail closed)", liste)
}
}
func TestRLSVerhindertInsertFuerFremdenAccount(t *testing.T) {
privileged := openTestStore(t)
restricted := openRestrictedTestStore(t)
ctx := context.Background()
accA := testAccountID(t, privileged)
accB := testAccountID(t, privileged)
err := restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
_, err := restricted.CreateAbteilung(scoped, accB, "Boesartig-Eingeschleust")
return err
})
if err == nil {
t.Fatal("RLS-LECK: INSERT für fremde account_id im Kontext A wurde nicht abgelehnt")
}
}

View File

@@ -43,7 +43,7 @@ func scanUser(row interface {
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
// speichert nur, es hasht nicht selbst.
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO app_user (account_id, email, password_hash, role)
VALUES ($1, $2, $3, $4)
RETURNING `+userColumns,
@@ -59,7 +59,7 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
@@ -72,7 +72,7 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
// GetUser liest einen Nutzer anhand seiner ID.
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
@@ -86,7 +86,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
// Admin-Bereich (Account-Detailansicht).
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
`, accountID)
if err != nil {
@@ -108,12 +108,26 @@ 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.db(ctx).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
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
if err != nil {
return fmt.Errorf("store: set user active: %w", err)
}

View File

@@ -16,17 +16,30 @@ import (
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
// muss belegbar sein.
type Werkzeug struct {
ID string
AccountID *string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
ID string
AccountID *string
Name string
Anbieter string
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
Verarbeitungslaender []string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
// übrigen Booleans hier.
DPFZertifiziert bool
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
@@ -36,37 +49,39 @@ type Werkzeug struct {
}
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
// Create/Update — bei 14 Feldern lesbarer als eine positionale
// Create/Update — bei 15 Feldern lesbarer als eine positionale
// Parameterliste.
type WerkzeugInput struct {
AccountID *string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
Zertifizierungen []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
Quelle string
AccountID *string
Name string
Anbieter string
Verarbeitungslaender []string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
DPFZertifiziert bool
AufbewahrungTage *int
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
Quelle string
}
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
func scanWerkzeug(row interface {
Scan(dest ...any) error
}) (Werkzeug, error) {
var w Werkzeug
err := row.Scan(
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
)
return w, err
}
@@ -77,6 +92,9 @@ func scanWerkzeug(row interface {
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
// Fall wie früher bei finding.sources).
func normalizeWerkzeugSlices(in *WerkzeugInput) {
if in.Verarbeitungslaender == nil {
in.Verarbeitungslaender = []string{}
}
if in.Zertifizierungen == nil {
in.Zertifizierungen = []string{}
}
@@ -86,20 +104,23 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
if in.Einschraenkungen == nil {
in.Einschraenkungen = []string{}
}
if in.Subprozessoren == nil {
in.Subprozessoren = []string{}
}
}
// CreateWerkzeug legt einen Katalogeintrag an.
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
normalizeWerkzeugSlices(&in)
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO werkzeug (
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING `+werkzeugColumns,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
@@ -116,16 +137,16 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
normalizeWerkzeugSlices(&in)
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
UPDATE werkzeug SET
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
letzte_pruefung = $13, quelle = $14, updated_at = now()
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
letzte_pruefung = $15, quelle = $16, updated_at = now()
WHERE id = $1
RETURNING `+werkzeugColumns,
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
@@ -140,7 +161,7 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
row := s.db(ctx).QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
w, err := scanWerkzeug(row)
if errors.Is(err, pgx.ErrNoRows) {
return Werkzeug{}, ErrNotFound
@@ -153,7 +174,7 @@ func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
// DeleteWerkzeug entfernt einen Katalogeintrag.
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete werkzeug: %w", err)
}
@@ -167,7 +188,7 @@ func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT `+werkzeugColumns+` FROM werkzeug w
WHERE (w.account_id IS NULL AND NOT EXISTS (
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
@@ -197,7 +218,7 @@ func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) (
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
// Mandanten-Sperrungen.
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
`)
if err != nil {
@@ -227,7 +248,7 @@ func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
var count int
var lastUpdate time.Time
err := s.Pool.QueryRow(ctx, `
err := s.db(ctx).QueryRow(ctx, `
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
`).Scan(&count, &lastUpdate)
if err != nil {
@@ -250,7 +271,7 @@ type WerkzeugSperre struct {
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
var sp WerkzeugSperre
err := s.Pool.QueryRow(ctx, `
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
VALUES ($1, $2, $3)
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
@@ -263,7 +284,7 @@ func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID,
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
tag, err := s.Pool.Exec(ctx, `
tag, err := s.db(ctx).Exec(ctx, `
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
`, accountID, werkzeugID)
if err != nil {
@@ -277,7 +298,7 @@ func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
rows, err := s.Pool.Query(ctx, `
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
`, accountID)

View File

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

View File

@@ -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,
})

View File

@@ -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"
@@ -290,11 +291,24 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
for i, w := range werkzeuge {
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
}}
}
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})
@@ -330,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
@@ -347,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"),

View File

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

View File

@@ -1,6 +1,7 @@
package web
import (
"context"
"net/http"
"time"
@@ -8,8 +9,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 +25,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-
@@ -54,20 +60,52 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
ctx := r.Context()
acc, err := s.store.CreateAccount(ctx, accountName)
// Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht
// ja gerade erst) — WithTenantScope startet die RLS-Transaktion
// zunächst ohne Mandant, SetTenantScope trägt die neue ID nach,
// sobald CreateAccount sie liefert. Ohne das würden die anschließenden
// Schreibzugriffe auf genehmiger_rolle/loeschfrist_einstellung (beide
// RLS-geschützt) von der Policy abgelehnt.
var errMsg string
err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error {
acc, err := s.store.CreateAccount(ctx, accountName)
if err != nil {
errMsg = "Konto konnte nicht angelegt werden"
return err
}
if err := s.store.SetTenantScope(ctx, acc.ID, false); err != nil {
errMsg = "Interner Fehler"
return err
}
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
if err != nil {
errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"
return err
}
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
errMsg = "Genehmiger-Rollen konnten nicht angelegt werden"
return err
}
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
errMsg = "Löschfristen konnten nicht angelegt werden"
return err
}
// r.WithContext(ctx): startSession muss in DERSELBEN Transaktion
// laufen wie CreateUser darüber — sonst sieht die Session-Zeile
// (andere Verbindung über s.Pool, da r.Context() sonst ohne Tx
// wäre) den gerade erst angelegten, noch nicht committeten
// Nutzer nicht und die Fremdschlüsselprüfung schlägt fehl.
if err := s.startSession(w, r.WithContext(ctx), user.ID); err != nil {
errMsg = "Sitzung konnte nicht gestartet werden"
return err
}
return nil
})
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
return
}
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
if errMsg == "" {
errMsg = "Registrierung fehlgeschlagen"
}
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg})
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -140,6 +178,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
return err
}
setSessionCookie(w, r, token, expiresAt)
return nil
}
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
// startSession (regulärer Login) und handleBetreiberLoginAls
// (Support-Login).
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
@@ -149,5 +195,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
SameSite: http.SameSiteStrictMode,
Expires: expiresAt,
})
return nil
}

View File

@@ -0,0 +1,98 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user.Role != "admin" {
t.Errorf("Role = %q, want admin", user.Role)
}
acc, err := fs.GetAccount(context.Background(), user.AccountID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if acc.Name != "Neue Firma GmbH" {
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
}
// Der neue Login funktioniert tatsächlich.
loginResp := postForm(t, s, nil, "/login", url.Values{
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
})
if loginResp.Code != http.StatusSeeOther {
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
}
}
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Pflicht") {
t.Errorf("expected a validation error, got: %s", resp.Body.String())
}
}
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
acc, err := fs.CreateAccount(context.Background(), "Alter Name")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
got, err := fs.GetAccount(context.Background(), acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Neuer Name" {
t.Errorf("Name = %q, want Neuer Name", got.Name)
}
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
if !strings.Contains(detail.Body.String(), "Neuer Name") {
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
}
}
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -2,6 +2,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 {
@@ -49,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.
@@ -61,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"),
})
@@ -72,14 +87,123 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
}
}
type betreiberAccountNeuData struct {
Title string
Nav navData
Error string
}
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountName := r.FormValue("account_name")
email := r.FormValue("email")
password := r.FormValue("password")
if accountName == "" || email == "" || password == "" {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
ctx := r.Context()
acc, err := s.store.CreateAccount(ctx, accountName)
if err != nil {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if err := s.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
}
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
}
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
// audit_log laufen).
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
return
}
id := r.PathValue("id")
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
}
type betreiberUserView struct {
Email string
Role string
ID string
Email string
Role string
Active bool
}
type betreiberAccountDetailData struct {
Title string
Nav navData
AccountID string
Name string
Users []betreiberUserView
CreatedAt string
@@ -100,11 +224,11 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
}
data := betreiberAccountDetailData{
Title: "Account", Nav: navFor(r), Name: acc.Name,
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
}
for _, u := range users {
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
@@ -120,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,
@@ -143,3 +284,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request)
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
// gerade im Kontext eines fremden Kontos gehandelt wird.
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
betreiber := currentUser(r)
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
if err != nil || ziel.AccountID != r.PathValue("accountID") {
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
return
}
if !ziel.Active {
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
return
}
token, err := auth.NewSessionToken()
if err != nil {
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
return
}
expiresAt := time.Now().Add(auth.SessionDuration)
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
setSessionCookie(w, r, token, expiresAt)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -14,11 +14,79 @@ 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
}
}
return false
}
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
// (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
// Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
// künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
// 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
// v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
// — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
var gueltigeZwecke = []string{
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
}
func istGueltigerZweck(v string) bool {
for _, z := range gueltigeZwecke {
if z == v {
return true
}
}
@@ -40,17 +108,69 @@ func splitListe(s string) []string {
}
type werkzeugListItem struct {
ID string
Name string
Anbieter string
Verarbeitungsort string
LetztePruefung string
ID string
Name string
Anbieter string
Verarbeitungslaender string
DPFZertifiziert bool
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
Zertifizierungen string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS string
}
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
// sofortigen Löschung) verwechselt wird.
func formatAufbewahrung(tage *int) string {
if tage == nil {
return "unbekannt"
}
if *tage == 0 {
return "0 Tage (sofort)"
}
return strconv.Itoa(*tage) + " Tage"
}
type werkzeugListeData struct {
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 "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.
@@ -60,11 +180,30 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
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,
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
DPFZertifiziert: wz.DPFZertifiziert,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
@@ -77,29 +216,67 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
type werkzeugFormData struct {
Title string
Nav navData
ActionBase string
ID string // leer bei "neu"
Name string
Anbieter string
Verarbeitungsorte []string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
Zertifizierungen string
GeeigneteZwecke string
Einschraenkungen string
LetztePruefung string
Quelle string
Error string
Title string
Nav navData
ActionBase string
ID string // leer bei "neu"
Name string
Anbieter string
LaenderOptionen []string
LaenderAusgewaehlt map[string]bool
DPFZertifiziert bool
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
Zertifizierungen string
Subprozessoren string
ZweckeOptionen []string
ZweckeAusgewaehlt map[string]bool
Einschraenkungen string
LetztePruefung string
Quelle string
Error string
}
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
// gespeicherten oder erneut eingereichten Liste von Zwecken.
func auswahlSet(ausgewaehlt []string) map[string]bool {
m := make(map[string]bool, len(ausgewaehlt))
for _, z := range ausgewaehlt {
m[z] = true
}
return m
}
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
aufbewahrung := ""
if wz.AufbewahrungTage != nil {
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
}
return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
Name: wz.Name, Anbieter: wz.Anbieter,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
}
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
data := werkzeugFormData{
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -113,9 +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")
@@ -123,23 +305,39 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
if name == "" || anbieter == "" || quelle == "" {
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
}
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
var aufbewahrungTage *int
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
wert, err := strconv.Atoi(rohwert)
if err != nil || wert < 0 {
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
}
aufbewahrungTage = &wert
}
var geeigneteZwecke []string
for _, z := range r.Form["geeignete_zwecke"] {
if istGueltigerZweck(z) {
geeigneteZwecke = append(geeigneteZwecke, z)
}
}
return store.WerkzeugInput{
AccountID: accountID,
Name: name,
Anbieter: anbieter,
Verarbeitungsort: verarbeitungsort,
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on",
TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: aufbewahrungTage,
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
LetztePruefung: letztePruefung,
Quelle: quelle,
AccountID: accountID,
Name: name,
Anbieter: anbieter,
Verarbeitungslaender: verarbeitungslaender,
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on",
TrainingStandard: r.FormValue("training_standard") == "on",
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
AufbewahrungTage: aufbewahrungTage,
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
GeeigneteZwecke: geeigneteZwecke,
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
LetztePruefung: letztePruefung,
Quelle: quelle,
}, ""
}
@@ -148,11 +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",
Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"),
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
Quelle: r.FormValue("quelle"), Error: errMsg,
}
@@ -192,15 +394,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
data := werkzeugFormData{
Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

View File

@@ -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")
}
}

View File

@@ -0,0 +1,207 @@
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
// der Betreiber pflegt den plattformweiten Standard
// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
// feste, bekannte Liste der vom System versendeten E-Mails — aktuell
// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt,
// aber bewusst als Liste-mit-Bearbeiten-Seite gebaut (wie Werkzeugkatalog/
// Nutzerverwaltung), nicht als eine Seite mit allen Formularen gestapelt
// — mit der Zeit kommen weitere Benachrichtigungstypen dazu.
package web
import (
"errors"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
type emailVorlageTypInfo struct {
Typ string
Label string
Platzhalter string
}
var emailVorlageTypen = []emailVorlageTypInfo{
{
Typ: "passwort_zuruecksetzen",
Label: "Passwort zurücksetzen",
Platzhalter: "{{link}} — der Zurücksetzen-Link, wird beim Versand eingesetzt.",
},
}
func findEmailVorlageTyp(typ string) (emailVorlageTypInfo, bool) {
for _, t := range emailVorlageTypen {
if t.Typ == typ {
return t, true
}
}
return emailVorlageTypInfo{}, false
}
// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
// Vorlage — aktuell nur {{link}} (Passwort-Zurücksetzen-Link).
func renderEmailVorlage(text, link string) string {
return strings.ReplaceAll(text, "{{link}}", link)
}
// ─── Mandant (Ebene 4, admin-only): eigene Übersteuerung ───────────────
type emailVorlageZeile struct {
Typ string
Label string
IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
}
type emailVorlagenListeData struct {
Title string
Nav navData
Zeilen []emailVorlageZeile
ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
}
func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen", Nav: navFor(r), ActionBase: "/verwaltung/email-vorlagen"}
for _, t := range emailVorlageTypen {
_, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label, IstUebersteuert: err == nil})
}
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type emailVorlageBearbeitenData struct {
Title string
Nav navData
Typ string
Label string
Platzhalter string
Betreff string
Text string
IstUebersteuert bool
ActionBase string
Error string
Gespeichert bool
}
func (s *Server) handleEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
info, ok := findEmailVorlageTyp(typ)
if !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
accountID := currentUser(r).AccountID
resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, typ)
if err != nil {
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
_, uebersteuert := s.store.GetEmailVorlage(r.Context(), &accountID, typ)
data := emailVorlageBearbeitenData{
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: uebersteuert == nil,
ActionBase: "/verwaltung/email-vorlagen", Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := currentUser(r).AccountID
betreff := strings.TrimSpace(r.FormValue("betreff"))
text := strings.TrimSpace(r.FormValue("text"))
if betreff == "" || text == "" {
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
return
}
if _, err := s.store.UpsertEmailVorlage(r.Context(), &accountID, typ, betreff, text); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}
func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
accountID := currentUser(r).AccountID
if err := s.store.DeleteEmailVorlage(r.Context(), &accountID, typ); err != nil && !errors.Is(err, store.ErrNotFound) {
http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}
// ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r), ActionBase: "/betreiber/email-vorlagen"}
for _, t := range emailVorlageTypen {
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label})
}
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleBetreiberEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
info, ok := findEmailVorlageTyp(typ)
if !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
v, err := s.store.GetEmailVorlage(r.Context(), nil, typ)
if err != nil {
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := emailVorlageBearbeitenData{
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
Betreff: v.Betreff, Text: v.Text, ActionBase: "/betreiber/email-vorlagen",
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
betreff := strings.TrimSpace(r.FormValue("betreff"))
text := strings.TrimSpace(r.FormValue("text"))
if betreff == "" || text == "" {
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
return
}
if _, err := s.store.UpsertEmailVorlage(r.Context(), nil, typ, betreff, text); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/betreiber/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}

View File

@@ -0,0 +1,113 @@
package web_test
import (
"net/http"
"net/url"
"regexp"
"strings"
"testing"
)
func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
listResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
if listResp.Code != http.StatusOK {
t.Fatalf("Liste-Status = %d, want 200", listResp.Code)
}
if !strings.Contains(listResp.Body.String(), "/verwaltung/email-vorlagen/passwort_zuruecksetzen") {
t.Fatalf("erwartet Link zur Bearbeiten-Seite, body: %s", listResp.Body.String())
}
getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
if getResp.Code != http.StatusOK {
t.Fatalf("GET Bearbeiten-Seite status = %d, want 200", getResp.Code)
}
if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") {
t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String())
}
saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"},
})
if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
}
afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
body := afterResp.Body.String()
if !strings.Contains(body, "Mein eigener Betreff") {
t.Fatalf("gespeicherter Betreff fehlt, body: %s", body)
}
if !strings.Contains(body, "(eigene Vorlage)") {
t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body)
}
resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen/zuruecksetzen", url.Values{})
if resetResp.Code != http.StatusSeeOther {
t.Fatalf("reset status = %d, want 303", resetResp.Code)
}
finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") {
t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv")
}
}
func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber")
saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"},
})
if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
}
getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen")
if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") {
t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String())
}
}
func TestNichtAdminKannEmailVorlagenNichtBearbeiten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.Code)
}
}
func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) {
fs := newFakeStore()
s, fm := newServerWithMailer(t, fs)
cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin")
if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."},
}); resp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", resp.Code)
}
if resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"vorlage-admin@example.com"}}); resp.Code != http.StatusOK {
t.Fatalf("passwort-vergessen status = %d, want 200", resp.Code)
}
if len(fm.Sent) != 1 {
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
}
if fm.Sent[0].Subject != "Firmenspezifischer Betreff" {
t.Fatalf("Subject = %q, want die firmenspezifische Vorlage", fm.Sent[0].Subject)
}
if !strings.Contains(fm.Sent[0].Body, "Hallo! Link: ") || strings.Contains(fm.Sent[0].Body, "{{link}}") {
t.Fatalf("Body = %q, want Platzhalter ersetzt und Rest der eigenen Vorlage erhalten", fm.Sent[0].Body)
}
if !regexp.MustCompile(`/passwort-zuruecksetzen/[a-f0-9]+`).MatchString(fm.Sent[0].Body) {
t.Fatalf("kein Reset-Link im Body gefunden: %q", fm.Sent[0].Body)
}
}

View File

@@ -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
}

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

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

View File

@@ -11,15 +11,49 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}
type indexData struct {
Title string
Nav navData
Title string
Nav navData
OffeneAntraege int
ZeigePosteingang bool
OffenerPosteingang int
}
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
// Überblick statt reiner Navigations-Duplikate (die Links stehen
// eh schon in der Seitenleiste): eigene offene Anträge, und für die
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
// bei jedem Login geladen wird, zu teuer.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
data := indexData{Title: "Start", Nav: navFor(r)}
user := currentUser(r)
nav := navFor(r)
data := indexData{Title: "Start", Nav: nav}
if !nav.IsBetreiber {
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
if err == nil {
for _, a := range eigene {
if a.Status == "eingereicht" {
data.OffeneAntraege++
}
}
}
}
if nav.IsFachebene {
data.ZeigePosteingang = true
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
if err == nil {
for _, a := range alle {
if a.Status == "eingereicht" {
data.OffenerPosteingang++
}
}
}
}
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

View File

@@ -0,0 +1,65 @@
package web_test
import (
"net/http"
"strings"
"testing"
)
func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
postForm(t, s, cookie, "/antraege", fullAntragForm())
resp := getWithCookie(t, s, cookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, `href="/antraege"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Meine offenen Anträge") {
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", body)
}
}
func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, `href="/faelle"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Posteingang") {
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", body)
}
}
func TestIndexZeigtKeinenPosteingangFuerMitarbeiter(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := getWithCookie(t, s, cookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), "Posteingang") {
t.Errorf("expected no Posteingang tile for mitarbeiter, got: %s", resp.Body.String())
}
}
func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := getWithCookie(t, s, betreiberCookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `href="/betreiber"`) || !strings.Contains(resp.Body.String(), "Zur Plattform") {
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
}
if strings.Contains(resp.Body.String(), "Antrag stellen") {
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
}
}

View File

@@ -0,0 +1,120 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
cookies := resp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("expected a new session cookie")
}
neueCookie := cookies[0]
// Die neue Sitzung ist tatsächlich der Kunden-Login.
protected := getWithCookie(t, s, neueCookie, "/antraege")
if protected.Code != http.StatusOK {
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
}
}
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
neueCookie := loginResp.Result().Cookies()[0]
resp := getWithCookie(t, s, neueCookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
}
}
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
var gefunden bool
for _, e := range fs.auditLog {
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
gefunden = true
}
}
if !gefunden {
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
}
}
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
})
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
}
}
func TestMitarbeiterCannotLoginAls(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -0,0 +1,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",
}

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

View File

@@ -11,25 +11,40 @@ package web
import (
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
type mandantWerkzeugItem struct {
ID string
Name string
Anbieter string
Gesperrt bool
ID string
Name string
Anbieter string
Verarbeitungslaender string
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS string
Gesperrt bool
}
type eigenesWerkzeugItem struct {
ID string
Name string
Anbieter string
ID string
Name string
Anbieter string
Verarbeitungslaender string
LetztePruefung string
}
type mandantWerkzeugListeData struct {
Title string
Nav navData
Werkzeuge []mandantWerkzeugItem
Filter werkzeugFilter
ZweckeOptionen []string
Pagination pagination
EigeneWerkzeuge []eigenesWerkzeugItem
}
@@ -59,17 +74,40 @@ 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, Gesperrt: gesperrt[wz.ID],
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
Gesperrt: gesperrt[wz.ID],
})
}
for _, wz := range eigene {
if wz.AccountID == nil {
continue // zentral, oben schon gelistet
}
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter})
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
})
}
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
@@ -77,7 +115,11 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
}
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
data := werkzeugFormData{
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -121,15 +163,7 @@ func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Re
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
data := werkzeugFormData{
Title: "Eigenes Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

View File

@@ -12,44 +12,85 @@ const sessionCookieName = "deklarix_session"
type contextKey int
const userContextKey contextKey = iota
const (
userContextKey contextKey = iota
impersonatorContextKey
)
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
// sonst leer.
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return store.User{}, false
return store.User{}, "", false
}
sess, err := s.store.GetSession(r.Context(), cookie.Value)
if err != nil {
return store.User{}, false
return store.User{}, "", false
}
if time.Now().After(sess.ExpiresAt) {
return store.User{}, false
return store.User{}, "", false
}
user, err := s.store.GetUser(r.Context(), sess.UserID)
if err != nil || !user.Active {
return store.User{}, false
return store.User{}, "", false
}
impersonator := ""
if sess.ImpersonatedByUserID != nil {
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
impersonator = betreiber.Email
}
}
return user, impersonator, true
}
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
ctx := context.WithValue(r.Context(), userContextKey, user)
if impersonator != "" {
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
}
return r.WithContext(ctx)
}
// withTenantScope führt next innerhalb einer Postgres-Transaktion aus,
// die die Sitzungsvariablen für Row-Level-Security setzt (siehe
// store.WithTenantScope) — jede require*-Middleware ruft das anstelle
// eines direkten next(w, r) auf, damit JEDE Store-Anfrage innerhalb
// dieses Requests automatisch auf den angemeldeten Mandanten begrenzt
// ist, nicht nur die, die ein Handler zufällig selbst noch mal prüft.
// Ein Fehler hier ist ein Infrastrukturproblem (z. B. DB down), kein
// normaler Handler-Fehlerpfad — 500 statt eines spezifischeren Codes.
func (s *Server) withTenantScope(w http.ResponseWriter, r *http.Request, user store.User, next http.HandlerFunc) {
err := s.store.WithTenantScope(r.Context(), user.AccountID, user.Role == "betreiber", func(ctx context.Context) error {
next(w, r.WithContext(ctx))
return nil
})
if err != nil {
http.Error(w, "Datenbankfehler: "+err.Error(), http.StatusInternalServerError)
}
return user, true
}
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -59,12 +100,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -76,7 +117,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
// verraten, dass unter dieser URL überhaupt etwas existiert.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -85,7 +126,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -106,7 +147,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
// nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -115,7 +156,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -124,7 +165,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -133,7 +174,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -142,9 +183,10 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
// currentUser zu geben — die Navigation braucht nur, ob ein
// Plattform-Link gezeigt werden soll.
type navData struct {
IsBetreiber bool
IsFachebene bool
IsAdmin bool
IsBetreiber bool
IsFachebene bool
IsAdmin bool
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
@@ -155,8 +197,9 @@ func navFor(r *http.Request) navData {
return navData{
IsBetreiber: role == "betreiber",
// admin sieht die Fachebene mit, siehe requireFachebene.
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
IsAdmin: role == "admin",
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
IsAdmin: role == "admin",
Impersonator: currentImpersonator(r),
}
}
@@ -171,3 +214,10 @@ func currentUser(r *http.Request) store.User {
}
return user
}
// currentImpersonator liefert die E-Mail des Betreibers, falls die
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
func currentImpersonator(r *http.Request) string {
v, _ := r.Context().Value(impersonatorContextKey).(string)
return v
}

View File

@@ -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]
}

View File

@@ -0,0 +1,106 @@
// 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)
betreff, text := "Deklarix — Passwort zurücksetzen", "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."
if vorlage, err := s.store.ResolveEmailVorlage(r.Context(), user.AccountID, "passwort_zuruecksetzen"); err == nil {
betreff, text = vorlage.Betreff, vorlage.Text
}
// 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, betreff, renderEmailVorlage(text, link)); 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)
}
}

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

View File

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

View File

@@ -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"
)
@@ -28,7 +29,17 @@ var staticFS embed.FS
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
// WithTenantScope/SetTenantScope: siehe internal/store/tenant_scope.go
// (Row-Level-Security-Sitzungskontext). fakeStore implementiert beide
// als reinen Passthrough — Tests laufen ohne echtes Postgres/RLS,
// die Isolation wird dort weiterhin (wie bisher) auf Anwendungsebene
// geprüft; die tatsächliche RLS-Durchsetzung hat eigene Tests gegen
// echtes Postgres, siehe internal/store/tenant_scope_test.go.
WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error
SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error
CreateAccount(ctx context.Context, name string) (store.Account, error)
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
@@ -38,7 +49,18 @@ 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)
UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error)
GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error)
ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error)
DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) 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)
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
@@ -74,6 +96,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
@@ -92,11 +135,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)
@@ -106,6 +153,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
store: st,
regelwerk: regelwerk,
templates: tmpl,
mailer: mailer,
}
mux := http.NewServeMux()
@@ -116,6 +164,19 @@ 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 /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe))
mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm))
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageSpeichern))
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen))
mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
mux.HandleFunc("GET /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageBearbeitenForm))
mux.HandleFunc("POST /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern))
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))
@@ -149,9 +210,23 @@ 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))
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))

View File

@@ -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,24 +39,111 @@ 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
emailVorlagen map[string]store.EmailVorlage // key: accountKeyFor(accountID)+"|"+typ
}
// accountKeyFor macht nil und "" für die Plattform-Vorlage im
// fakeStore-Key ununterscheidbar von einer echten, aber leeren
// account_id — genügt für Tests, da echte account_ids nie leer sind.
func accountKeyFor(accountID *string) string {
if accountID == nil {
return ""
}
return *accountID
}
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{},
emailVorlagen: map[string]store.EmailVorlage{
// Platzhalter für den plattformweiten Standard, wie ihn
// Migration 0022 für echtes Postgres seedet — sonst würde
// ResolveEmailVorlage im fakeStore ErrNotFound liefern,
// wo die echte DB immer einen Treffer hat.
"|passwort_zuruecksetzen": {
Typ: "passwort_zuruecksetzen", Betreff: "Deklarix — Passwort zurücksetzen",
Text: "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.",
},
},
}
}
// WithTenantScope/SetTenantScope sind im fakeStore reine Passthroughs —
// fakeStore hat kein RLS-Äquivalent, Tests prüfen Isolation weiterhin
// wie bisher auf Anwendungsebene (AccountID-Vergleich in den Handlern).
func (f *fakeStore) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
return fn(ctx)
}
func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
return nil
}
func (f *fakeStore) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
key := accountKeyFor(accountID) + "|" + typ
v := store.EmailVorlage{ID: f.newID(), AccountID: accountID, Typ: typ, Betreff: betreff, Text: text, UpdatedAt: time.Now()}
f.emailVorlagen[key] = v
return v, nil
}
func (f *fakeStore) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.emailVorlagen[accountKeyFor(accountID)+"|"+typ]
if !ok {
return store.EmailVorlage{}, store.ErrNotFound
}
return v, nil
}
func (f *fakeStore) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
if v, ok := f.emailVorlagen[accountID+"|"+typ]; ok {
return v, nil
}
if v, ok := f.emailVorlagen["|"+typ]; ok {
return v, nil
}
return store.EmailVorlage{}, store.ErrNotFound
}
func (f *fakeStore) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
f.mu.Lock()
defer f.mu.Unlock()
key := accountKeyFor(accountID) + "|" + typ
if _, ok := f.emailVorlagen[key]; !ok {
return store.ErrNotFound
}
delete(f.emailVorlagen, key)
return nil
}
func (f *fakeStore) newID() string {
f.nextID++
return fmt.Sprintf("id-%d", f.nextID)
@@ -70,6 +158,18 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
return acc, nil
}
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
acc.Name = name
f.accounts[id] = acc
return acc, nil
}
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -160,6 +260,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()
@@ -180,6 +347,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
return sess, nil
}
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
f.sessions[token] = sess
return sess, nil
}
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -388,9 +563,9 @@ func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput)
defer f.mu.Unlock()
w := store.Werkzeug{
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage,
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
f.werkzeuge[w.ID] = w
@@ -404,10 +579,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
@@ -527,6 +702,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,
@@ -553,11 +930,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
@@ -713,6 +1097,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)

View File

@@ -59,7 +59,15 @@ h1, h2, h3 {
color: var(--color-heading);
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.5em;
margin: 0 0 0.6em;
}
h2 {
font-size: 1.25rem;
}
h3 {
font-size: 1.0625rem;
}
h1 {
@@ -275,6 +283,38 @@ nav button {
margin-top: 24px;
}
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
.impersonation-banner {
background: var(--color-mittel-bg);
color: var(--color-mittel);
padding: 10px 16px;
font-size: 0.875rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--color-mittel);
}
.impersonation-banner form {
margin: 0;
}
.impersonation-banner button {
margin-top: 0;
min-height: 32px;
padding: 4px 12px;
font-size: 0.8125rem;
}
@media (min-width: 960px) {
.impersonation-banner {
margin-left: var(--sidebar-width);
}
}
.fehler {
background: var(--color-hoch-bg);
color: var(--color-hoch);
@@ -360,6 +400,16 @@ nav button {
color: var(--color-mittel);
}
.status-niedrig {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-hoch {
background: var(--color-hoch-bg);
color: var(--color-hoch);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
@@ -390,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 {
@@ -500,8 +551,8 @@ button.entfernen {
}
.fragebogen legend {
font-weight: 500;
font-size: 0.9375rem;
font-weight: 600;
font-size: 1rem;
padding: 0 4px;
}
@@ -513,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;
@@ -563,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
@@ -651,4 +1057,17 @@ tbody tr:hover {
padding: 32px 40px;
width: 100%;
}
/* Tabellen-/Listen-/Dashboard-Seiten verschwenden bei 1100px sichtbar
Platz auf breiten Bildschirmen, während Tabellen selbst schon
horizontal gescrollt/gequetscht werden müssen — .page-wide hebt die
Deckelung für genau diese Seiten an. Bewusst 1600px statt "kein
Limit": manche dieser Seiten (Fall-/Antrag-Detail) enthalten auch
Fließtext-Absätze, die auf einem Ultrawide-Monitor sonst unlesbar
lange Zeilen bekämen. Reine Formular-/Auth-Seiten (Login,
Registrieren, Einladung, ...) bleiben bei 1100px, dort ist die
schmalere Spalte weiterhin die bessere Lesbarkeit. */
.page-wide {
max-width: 1600px;
}
}

View File

@@ -3,9 +3,13 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Abteilungen</h1>
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
<div class="page page-wide">
<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>

View File

@@ -3,23 +3,43 @@
<head>{{template "head" .}}</head>
<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 page-wide">
<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}}">&larr; Zurück</button>{{end}}
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter &rarr;</button>{{end}}
</span>
</div>
{{end}}
</ul>
{{end}}
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
</form>
</div>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<p><a href="/antraege">&larr; Meine Anträge</a></p>
<h1>{{.Titel}}</h1>
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>

View File

@@ -3,16 +3,30 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<p><a href="/betreiber/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
<label for="name">Firmenname</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
<button type="submit">Umbenennen</button>
</form>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
<div class="beteiligter-kopf">
{{.Email}} <span class="rolle">({{.Role}})</span>
{{if not .Active}}<span class="status">deaktiviert</span>{{end}}
</div>
{{if .Active}}
<form method="post" action="/betreiber/accounts/{{$.AccountID}}/nutzer/{{.ID}}/anmelden-als" onsubmit="return confirm('Als {{.Email}} anmelden? Ihre eigene Sitzung wird dabei ersetzt.')">
<button type="submit">Als Nutzer anmelden</button>
</form>
{{end}}
</li>
{{end}}
</ul>

View File

@@ -0,0 +1,29 @@
{{define "betreiber-account-neu"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber/accounts">&larr; Accounts</a></p>
<h1>Firma anlegen</h1>
<p class="hinweis">
Legt eine Firma mit dem ersten Login (Rolle admin) an — dasselbe
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/betreiber/accounts">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
<input type="email" id="email" name="email" required>
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Firma anlegen</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -3,11 +3,22 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<p><a href="/betreiber">&larr; 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,6 +29,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">&larr; 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 &rarr;</button>
</form>
{{end}}
</span>
</div>
{{end}}
</div>
</body>

View File

@@ -3,11 +3,21 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<p><a href="/betreiber">&larr; 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">&larr; 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 &rarr;</button>
</form>
{{end}}
</span>
</div>
{{end}}
</div>
</body>

View File

@@ -3,12 +3,28 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<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>
<li><a class="stat-card stat-card-action" href="/betreiber/email-vorlagen"><span class="stat-card-label">E-Mail-Vorlagen &rarr;</span></a></li>
</ul>
</div>
</body>

View File

@@ -7,59 +7,113 @@
<p><a href="{{.ActionBase}}">&larr; Werkzeugkatalog</a></p>
<h1>{{.Title}}</h1>
<p class="hinweis">
Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out,
Jede Zusicherung hier (AVV, Verarbeitungsländer, Training-Opt-out,
Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind
Pflicht.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
<label for="anbieter">Anbieter</label>
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
<fieldset>
<legend>Verarbeitungsort</legend>
{{$gewaehlt := .Verarbeitungsort}}
{{range .Verarbeitungsorte}}
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
{{end}}
<fieldset class="form-section">
<legend>Grunddaten</legend>
<div class="form-grid">
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
</div>
<div>
<label for="anbieter">Anbieter</label>
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
</div>
<div class="form-full">
<label for="verarbeitungslaender">Verarbeitungsländer (Mehrfachauswahl: Strg/Cmd-Klick)</label>
<select id="verarbeitungslaender" name="verarbeitungslaender" multiple size="8" required>
{{range .LaenderOptionen}}
<option value="{{.}}" {{if index $.LaenderAusgewaehlt .}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<p class="hinweis">Tatsächliche Länder benennen, nicht "EU"/"Drittland" — die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere.</p>
</div>
</div>
</fieldset>
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
<label for="avv_url">AVV-URL</label>
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
<fieldset class="form-section">
<legend>Rechtliches &amp; Datenschutz</legend>
<div class="form-grid">
<div class="form-full">
<label>Zusicherungen</label>
<div class="chip-group">
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
<label><input type="checkbox" name="dpf_zertifiziert" {{if .DPFZertifiziert}}checked{{end}}> EU-US Data Privacy Framework (DPF) zertifiziert</label>
</div>
</div>
<div class="form-full">
<label for="avv_url">AVV-URL</label>
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
</div>
<div>
<label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label>
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
</div>
<div>
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
</div>
<div class="form-full">
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
</div>
</div>
</fieldset>
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
<fieldset class="form-section">
<legend>Einsatzbereich</legend>
<div class="form-grid">
<div class="form-full">
<label>Geeignete Zwecke</label>
<div class="chip-group">
{{range .ZweckeOptionen}}
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
{{end}}
</div>
</div>
<div class="form-full">
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
</div>
</div>
</fieldset>
<label for="aufbewahrung_tage">Aufbewahrung (Tage)</label>
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
<fieldset class="form-section">
<legend>Beleg</legend>
<div class="form-grid">
<div>
<label for="letzte_pruefung">Letzte Prüfung</label>
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
</div>
<div>
<label for="quelle">Quelle (Beleg)</label>
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
</div>
</div>
</fieldset>
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
<label for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
<label for="letzte_pruefung">Letzte Prüfung</label>
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
<label for="quelle">Quelle (Beleg)</label>
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
<button type="submit">Speichern</button>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
{{if .ID}}
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
<button type="submit" class="entfernen">Werkzeug löschen</button>
</form>
<div class="form-gefahrenzone">
<span class="hinweis">Ein gelöschtes Werkzeug kann nicht wiederhergestellt werden.</span>
<form method="post" action="{{.ActionBase}}/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
<button type="submit" class="entfernen">Werkzeug löschen</button>
</form>
</div>
{{end}}
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -3,23 +3,68 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Werkzeugkatalog</h1>
{{if not .Werkzeuge}}
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Werkzeuge}}
<li>
<a href="/betreiber/werkzeuge/{{.ID}}">
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
</a>
</li>
<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>
<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}}">&larr; Zurück</button>{{end}}
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter &rarr;</button>{{end}}
</span>
</div>
{{end}}
</ul>
{{end}}
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,49 @@
{{define "email-vorlage-bearbeiten"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<p><a href="{{.ActionBase}}">&larr; E-Mail-Vorlagen</a></p>
<div class="page-header">
<div>
<h1>{{.Label}}</h1>
<p class="hinweis">
Platzhalter in geschweiften Klammern werden beim Versand ersetzt.
Verfügbar: {{.Platzhalter}}
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
<div class="form-card">
<form method="post" action="{{.ActionBase}}/{{.Typ}}">
<fieldset class="form-section">
<legend>{{.Label}}{{if .IstUebersteuert}} <span class="hinweis">(eigene Vorlage)</span>{{else if eq .ActionBase "/verwaltung/email-vorlagen"}} <span class="hinweis">(Plattform-Standard, nicht angepasst)</span>{{end}}</legend>
<div class="form-grid">
<div class="form-full">
<label for="betreff">Betreff</label>
<input type="text" id="betreff" name="betreff" value="{{.Betreff}}" required>
</div>
<div class="form-full">
<label for="text">Text</label>
<textarea id="text" name="text" rows="10" required>{{.Text}}</textarea>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
{{if and .IstUebersteuert (eq .ActionBase "/verwaltung/email-vorlagen")}}
<div class="form-gefahrenzone">
<form method="post" action="/verwaltung/email-vorlagen/{{.Typ}}/zuruecksetzen" onsubmit="return confirm('Eigene Vorlage löschen und zum Plattform-Standard zurückkehren?')">
<button type="submit" class="entfernen">Auf Plattform-Standard zurücksetzen</button>
</form>
</div>
{{end}}
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,30 @@
{{define "email-vorlagen-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<div class="page-header">
<div>
<h1>{{.Title}}</h1>
<p class="hinweis">Betreff und Text der vom System versendeten E-Mails.</p>
</div>
</div>
{{if not .Zeilen}}
<p class="hinweis">Es gibt noch keine vom System versendeten E-Mails.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Zeilen}}
<li>
<a href="{{$.ActionBase}}/{{.Typ}}">
<strong>{{.Label}}</strong>
{{if .IstUebersteuert}}<span class="status status-published">eigene Vorlage</span>{{else}}<span class="status status-mittel">Plattform-Standard</span>{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -3,21 +3,41 @@
<head>{{template "head" .}}</head>
<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 page-wide">
<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}}">&larr; Zurück</button>{{end}}
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter &rarr;</button>{{end}}
</span>
</div>
{{end}}
</ul>
{{end}}
</form>
</div>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<p><a href="/faelle">&larr; Posteingang</a></p>
<h1>{{.Titel}}</h1>
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
@@ -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}}

View 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 page-wide">
<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}}

View File

@@ -0,0 +1,38 @@
{{define "freigaben-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<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}}

View 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 page-wide">
<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}}

View File

@@ -3,12 +3,31 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<h1>Willkommen bei Deklarix</h1>
<ul class="admin-kacheln">
<li><a href="/antraege/neu">Antrag stellen</a></li>
<li><a href="/antraege">Meine Anträge</a></li>
{{if .Nav.IsBetreiber}}
<ul class="stat-cards">
<li><a class="stat-card stat-card-action" href="/betreiber"><span class="stat-card-label">Zur Plattform &rarr;</span></a></li>
</ul>
{{else}}
<ul class="stat-cards">
<li><a class="stat-card stat-card-action" href="/antraege/neu"><span class="stat-card-label">Antrag stellen &rarr;</span></a></li>
<li>
<a class="stat-card{{if .OffeneAntraege}} stat-card-mittel{{end}}" href="/antraege">
<span class="stat-card-value">{{.OffeneAntraege}}</span>
<span class="stat-card-label">Meine offenen Anträge</span>
</a>
</li>
{{if .ZeigePosteingang}}
<li>
<a class="stat-card{{if .OffenerPosteingang}} stat-card-hoch{{end}}" href="/faelle">
<span class="stat-card-value">{{.OffenerPosteingang}}</span>
<span class="stat-card-label">Posteingang</span>
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>

View File

@@ -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,12 +32,24 @@
{{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 .IsAdmin}}<a href="/verwaltung/email-vorlagen">E-Mail-Vorlagen</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout">
<button type="submit">Abmelden</button>
</form>
</nav>
</header>
{{if .Impersonator}}
<div class="impersonation-banner">
Support-Zugriff durch <strong>{{.Impersonator}}</strong> — dies ist nicht Ihre eigene Sitzung.
<form method="post" action="/logout" style="display:inline">
<button type="submit">Sitzung beenden</button>
</form>
</div>
{{end}}
{{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-

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

View File

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

View File

@@ -3,55 +3,125 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<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}}
<ul class="beitraege-liste">
{{range .Werkzeuge}}
<li>
{{.Name}} · {{.Anbieter}}
{{if .Gesperrt}}
<span class="status">gesperrt</span>
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
<button type="submit">Entsperren</button>
</form>
{{else}}
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
<input type="text" name="grund" placeholder="Grund (optional)">
<button type="submit">Sperren</button>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<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">
<button type="submit">Entsperren</button>
</form>
{{else}}
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
<input type="text" name="grund" placeholder="Grund (optional)">
<button type="submit">Sperren</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</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">&larr; Zurück</button>
</form>
{{end}}
</li>
{{end}}
</ul>
{{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 &rarr;</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}}
<ul class="beitraege-liste">
{{range .EigeneWerkzeuge}}
<li>
<a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}} · {{.Anbieter}}</a>
</li>
{{end}}
</ul>
<div class="table-responsive">
<table>
<thead>
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
</thead>
<tbody>
{{range .EigeneWerkzeuge}}
<tr>
<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>

View File

@@ -3,7 +3,7 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<h1>Import-Ergebnis</h1>
<p class="fehler">
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr

View File

@@ -3,8 +3,22 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Nutzerverwaltung</h1>
<div class="page page-wide">
<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">&larr; 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 &rarr;</button>
</form>
{{end}}
</span>
</div>
{{end}}
<p><a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
</div>
</body>
</html>

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

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

View File

@@ -3,37 +3,62 @@
<head>{{template "head" .}}</head>
<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 page-wide">
<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}}">&larr; Zurück</button>{{end}}
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter &rarr;</button>{{end}}
</span>
</div>
{{end}}
</form>
</div>
</body>
</html>

View File

@@ -3,12 +3,16 @@
<head>{{template "head" .}}</head>
<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 page-wide">
<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}}

Some files were not shown because too many files have changed in this diff Show More